mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 23:16:44 +00:00
Compare commits
16 Commits
sora-remix
...
feature/ss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
380e1b7d56 | ||
|
|
63828349de | ||
|
|
5706f0ee9f | ||
|
|
e9e1dbff5e | ||
|
|
315eabc1e7 | ||
|
|
359dbc9d94 | ||
|
|
e157ea6ba2 | ||
|
|
dc3dba0665 | ||
|
|
81272da9ac | ||
|
|
926cad87b3 | ||
|
|
418ce449b7 | ||
|
|
4a02ab23ce | ||
|
|
984097c60b | ||
|
|
5550ec017e | ||
|
|
9e6752e0ee | ||
|
|
91a0eb7031 |
375
controller/oauth.go
Normal file
375
controller/oauth.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"one-api/model"
|
||||
"one-api/setting/system_setting"
|
||||
"one-api/src/oauth"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
"one-api/middleware"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// lazy init if needed
|
||||
_ = oauth.EnsureInitialized()
|
||||
|
||||
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 == "" || !strings.Contains(strings.ToLower(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
|
||||
}
|
||||
|
||||
// lazy init
|
||||
if err := oauth.EnsureInitialized(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error", "error_description": err.Error()})
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
if err := oauth.EnsureInitialized(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error", "error_description": err.Error()})
|
||||
return
|
||||
}
|
||||
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)
|
||||
issuer := settings.Issuer
|
||||
if issuer == "" {
|
||||
scheme := "https"
|
||||
if c.Request.TLS == nil {
|
||||
if hdr := c.Request.Header.Get("X-Forwarded-Proto"); hdr != "" {
|
||||
scheme = hdr
|
||||
} else {
|
||||
scheme = "http"
|
||||
}
|
||||
}
|
||||
issuer = scheme + "://" + c.Request.Host
|
||||
}
|
||||
|
||||
base := issuer + "/api"
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"issuer": issuer,
|
||||
"authorization_endpoint": base + "/oauth/authorize",
|
||||
"token_endpoint": base + "/oauth/token",
|
||||
"jwks_uri": base + "/.well-known/jwks.json",
|
||||
"grant_types_supported": settings.AllowedGrantTypes,
|
||||
"response_types_supported": []string{"code", "token"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"scopes_supported": []string{"openid", "profile", "email", "api:read", "api:write", "admin"},
|
||||
"default_private_key_path": settings.DefaultPrivateKeyPath,
|
||||
})
|
||||
}
|
||||
|
||||
// OAuthOIDCConfiguration OIDC discovery document
|
||||
func OAuthOIDCConfiguration(c *gin.Context) {
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
if !settings.Enabled {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "OAuth2 server is disabled"})
|
||||
return
|
||||
}
|
||||
issuer := settings.Issuer
|
||||
if issuer == "" {
|
||||
scheme := "https"
|
||||
if c.Request.TLS == nil {
|
||||
if hdr := c.Request.Header.Get("X-Forwarded-Proto"); hdr != "" {
|
||||
scheme = hdr
|
||||
} else {
|
||||
scheme = "http"
|
||||
}
|
||||
}
|
||||
issuer = scheme + "://" + c.Request.Host
|
||||
}
|
||||
base := issuer + "/api"
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"issuer": issuer,
|
||||
"authorization_endpoint": base + "/oauth/authorize",
|
||||
"token_endpoint": base + "/oauth/token",
|
||||
"userinfo_endpoint": base + "/oauth/userinfo",
|
||||
"jwks_uri": base + "/.well-known/jwks.json",
|
||||
"response_types_supported": []string{"code", "token"},
|
||||
"grant_types_supported": settings.AllowedGrantTypes,
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
"scopes_supported": []string{"openid", "profile", "email", "api:read", "api:write", "admin"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"default_private_key_path": settings.DefaultPrivateKeyPath,
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
tokenString := token
|
||||
|
||||
// 验证并解析JWT
|
||||
parsed, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, jwt.ErrTokenSignatureInvalid
|
||||
}
|
||||
pub := oauth.GetPublicKeyByKid(func() string {
|
||||
if v, ok := token.Header["kid"].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}())
|
||||
if pub == nil {
|
||||
return nil, jwt.ErrTokenUnverifiable
|
||||
}
|
||||
return pub, nil
|
||||
})
|
||||
if err != nil || !parsed.Valid {
|
||||
c.JSON(http.StatusOK, gin.H{"active": false})
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
c.JSON(http.StatusOK, gin.H{"active": false})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查撤销
|
||||
if jti, ok := claims["jti"].(string); ok && jti != "" {
|
||||
if revoked, _ := model.IsTokenRevoked(jti); revoked {
|
||||
c.JSON(http.StatusOK, gin.H{"active": false})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 有效
|
||||
resp := gin.H{"active": true}
|
||||
for k, v := range claims {
|
||||
resp[k] = v
|
||||
}
|
||||
resp["token_type"] = "Bearer"
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
token = c.PostForm("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing token parameter",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试解析JWT,若成功则记录jti到撤销表
|
||||
parsed, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, jwt.ErrTokenSignatureInvalid
|
||||
}
|
||||
pub := oauth.GetRSAPublicKey()
|
||||
if pub == nil {
|
||||
return nil, jwt.ErrTokenUnverifiable
|
||||
}
|
||||
return pub, nil
|
||||
})
|
||||
if err == nil && parsed != nil && parsed.Valid {
|
||||
if claims, ok := parsed.Claims.(jwt.MapClaims); ok {
|
||||
var jti string
|
||||
var exp int64
|
||||
if v, ok := claims["jti"].(string); ok {
|
||||
jti = v
|
||||
}
|
||||
if v, ok := claims["exp"].(float64); ok {
|
||||
exp = int64(v)
|
||||
} else if v, ok := claims["exp"].(int64); ok {
|
||||
exp = v
|
||||
}
|
||||
if jti != "" {
|
||||
// 如果没有exp,默认撤销至当前+TTL 10分钟
|
||||
if exp == 0 {
|
||||
exp = time.Now().Add(10 * time.Minute).Unix()
|
||||
}
|
||||
_ = model.RevokeToken(jti, exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// OAuthUserInfo returns OIDC userinfo based on access token
|
||||
func OAuthUserInfo(c *gin.Context) {
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
if !settings.Enabled {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "OAuth2 server is disabled"})
|
||||
return
|
||||
}
|
||||
// 需要 OAuthJWTAuth 中间件注入 claims
|
||||
claims, ok := middleware.GetOAuthClaims(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
|
||||
return
|
||||
}
|
||||
// scope 校验:必须包含 openid
|
||||
scope, _ := claims["scope"].(string)
|
||||
if !strings.Contains(" "+scope+" ", " openid ") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient_scope"})
|
||||
return
|
||||
}
|
||||
sub, _ := claims["sub"].(string)
|
||||
resp := gin.H{"sub": sub}
|
||||
// 若包含 profile/email scope,补充返回
|
||||
if strings.Contains(" "+scope+" ", " profile ") || strings.Contains(" "+scope+" ", " email ") {
|
||||
if uid, err := strconv.Atoi(sub); err == nil {
|
||||
if user, err2 := model.GetUserById(uid, false); err2 == nil && user != nil {
|
||||
if strings.Contains(" "+scope+" ", " profile ") {
|
||||
resp["name"] = user.DisplayName
|
||||
resp["preferred_username"] = user.Username
|
||||
}
|
||||
if strings.Contains(" "+scope+" ", " email ") {
|
||||
resp["email"] = user.Email
|
||||
resp["email_verified"] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
374
controller/oauth_client.go
Normal file
374
controller/oauth_client.go
Normal file
@@ -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
|
||||
}
|
||||
89
controller/oauth_keys.go
Normal file
89
controller/oauth_keys.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"one-api/logger"
|
||||
"one-api/src/oauth"
|
||||
)
|
||||
|
||||
type rotateKeyRequest struct {
|
||||
Kid string `json:"kid"`
|
||||
}
|
||||
|
||||
type genKeyFileRequest struct {
|
||||
Path string `json:"path"`
|
||||
Kid string `json:"kid"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
type importPemRequest struct {
|
||||
Pem string `json:"pem"`
|
||||
Kid string `json:"kid"`
|
||||
}
|
||||
|
||||
// RotateOAuthSigningKey rotates the OAuth2 JWT signing key (Root only)
|
||||
func RotateOAuthSigningKey(c *gin.Context) {
|
||||
var req rotateKeyRequest
|
||||
_ = c.BindJSON(&req)
|
||||
kid, err := oauth.RotateSigningKey(req.Kid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, "oauth signing key rotated: "+kid)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "kid": kid})
|
||||
}
|
||||
|
||||
// ListOAuthSigningKeys returns current and historical JWKS signing keys
|
||||
func ListOAuthSigningKeys(c *gin.Context) {
|
||||
keys := oauth.ListSigningKeys()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": keys})
|
||||
}
|
||||
|
||||
// DeleteOAuthSigningKey deletes a non-current key by kid
|
||||
func DeleteOAuthSigningKey(c *gin.Context) {
|
||||
kid := c.Param("kid")
|
||||
if kid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "kid required"})
|
||||
return
|
||||
}
|
||||
if err := oauth.DeleteSigningKey(kid); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, "oauth signing key deleted: "+kid)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// GenerateOAuthSigningKeyFile generates a private key file and rotates current kid
|
||||
func GenerateOAuthSigningKeyFile(c *gin.Context) {
|
||||
var req genKeyFileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Path == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "path required"})
|
||||
return
|
||||
}
|
||||
kid, err := oauth.GenerateAndPersistKey(req.Path, req.Kid, req.Overwrite)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, "oauth signing key generated to file: "+req.Path+" kid="+kid)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "kid": kid, "path": req.Path})
|
||||
}
|
||||
|
||||
// ImportOAuthSigningKey imports PEM text and rotates current kid
|
||||
func ImportOAuthSigningKey(c *gin.Context) {
|
||||
var req importPemRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Pem == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "pem required"})
|
||||
return
|
||||
}
|
||||
kid, err := oauth.ImportPEMKey(req.Pem, req.Kid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, "oauth signing key imported from PEM, kid="+kid)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "kid": kid})
|
||||
}
|
||||
326
examples/oauth/oauth-demo.html
Normal file
326
examples/oauth/oauth-demo.html
Normal file
@@ -0,0 +1,326 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OAuth2/OIDC 授权码 + PKCE 前端演示</title>
|
||||
<style>
|
||||
:root { --bg:#0b0c10; --panel:#111317; --muted:#aab2bf; --accent:#3b82f6; --ok:#16a34a; --warn:#f59e0b; --err:#ef4444; --border:#1f2430; }
|
||||
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background: var(--bg); color:#e5e7eb; }
|
||||
.wrap { max-width: 980px; margin: 32px auto; padding: 0 16px; }
|
||||
h1 { font-size: 22px; margin:0 0 16px; }
|
||||
.card { background: var(--panel); border:1px solid var(--border); border-radius: 10px; padding: 16px; margin: 12px 0; }
|
||||
.row { display:flex; gap:12px; flex-wrap:wrap; }
|
||||
.col { flex: 1 1 280px; display:flex; flex-direction:column; }
|
||||
label { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
|
||||
input, textarea, select { background:#0f1115; color:#e5e7eb; border:1px solid var(--border); padding:10px 12px; border-radius:8px; outline:none; }
|
||||
textarea { min-height: 100px; resize: vertical; }
|
||||
.btns { display:flex; gap:8px; flex-wrap:wrap; margin-top: 8px; }
|
||||
button { background:#1a1f2b; color:#e5e7eb; border:1px solid var(--border); padding:8px 12px; border-radius:8px; cursor:pointer; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color:white; }
|
||||
button.ok { background: var(--ok); border-color: var(--ok); color:white; }
|
||||
button.warn { background: var(--warn); border-color: var(--warn); color:black; }
|
||||
button.ghost { background: transparent; }
|
||||
.muted { color: var(--muted); font-size: 12px; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
.grid2 { display:grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
@media (max-width: 880px){ .grid2 { grid-template-columns: 1fr; } }
|
||||
.pill { padding: 3px 8px; border-radius:999px; font-size: 12px; border:1px solid var(--border); background:#0f1115; }
|
||||
.ok { color: #10b981; }
|
||||
.err { color: #ef4444; }
|
||||
.sep { height:1px; background: var(--border); margin: 12px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>OAuth2/OIDC 授权码 + PKCE 前端演示</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label>Issuer(可选,用于自动发现 /.well-known/openid-configuration)</label>
|
||||
<input id="issuer" placeholder="https://your-domain" />
|
||||
<div class="btns"><button class="" id="btnDiscover">自动发现端点</button></div>
|
||||
<div class="muted">提示:若未配置 Issuer,可直接填写下方端点。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col"><label>Authorization Endpoint</label><input id="authorization_endpoint" placeholder="https://domain/api/oauth/authorize" /></div>
|
||||
<div class="col"><label>Token Endpoint</label><input id="token_endpoint" placeholder="https://domain/api/oauth/token" /></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col"><label>UserInfo Endpoint(可选)</label><input id="userinfo_endpoint" placeholder="https://domain/api/oauth/userinfo" /></div>
|
||||
<div class="col"><label>Client ID</label><input id="client_id" placeholder="your-public-client-id" /></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col"><label>Redirect URI(当前页地址或你的回调)</label><input id="redirect_uri" /></div>
|
||||
<div class="col"><label>Scope</label><input id="scope" value="openid profile email" /></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col"><label>State</label><input id="state" /></div>
|
||||
<div class="col"><label>Nonce</label><input id="nonce" /></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col"><label>Code Verifier(自动生成,不会上送)</label><input id="code_verifier" class="mono" readonly /></div>
|
||||
<div class="col"><label>Code Challenge(S256)</label><input id="code_challenge" class="mono" readonly /></div>
|
||||
</div>
|
||||
<div class="btns">
|
||||
<button id="btnGenPkce">生成 PKCE</button>
|
||||
<button id="btnRandomState">随机 State</button>
|
||||
<button id="btnRandomNonce">随机 Nonce</button>
|
||||
<button id="btnMakeAuthURL">生成授权链接</button>
|
||||
<button id="btnAuthorize" class="primary">跳转授权</button>
|
||||
</div>
|
||||
<div class="row" style="margin-top:8px;">
|
||||
<div class="col">
|
||||
<label>授权链接(只生成不跳转)</label>
|
||||
<textarea id="authorize_url" class="mono" placeholder="(空)"></textarea>
|
||||
<div class="btns"><button id="btnCopyAuthURL">复制链接</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sep"></div>
|
||||
<div class="muted">说明:
|
||||
<ul>
|
||||
<li>本页为纯前端演示,适用于公开客户端(不需要 client_secret)。</li>
|
||||
<li>如跨域调用 Token/UserInfo,需要服务端正确设置 CORS;建议将此 demo 部署到同源域名下。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="sep"></div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label>粘贴 OIDC Discovery JSON(/.well-known/openid-configuration)</label>
|
||||
<textarea id="conf_json" class="mono" placeholder='{"issuer":"https://...","authorization_endpoint":"...","token_endpoint":"...","userinfo_endpoint":"..."}'></textarea>
|
||||
<div class="btns">
|
||||
<button id="btnParseConf">解析并填充端点</button>
|
||||
<button id="btnGenConf">用当前端点生成 JSON</button>
|
||||
</div>
|
||||
<div class="muted">可将服务端返回的 OIDC Discovery JSON 粘贴到此处,点击“解析并填充端点”。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label>授权结果</label>
|
||||
<div id="authResult" class="muted">等待授权...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid2" style="margin-top:12px;">
|
||||
<div>
|
||||
<label>Access Token</label>
|
||||
<textarea id="access_token" class="mono" placeholder="(空)"></textarea>
|
||||
<div class="btns">
|
||||
<button id="btnCopyAT">复制</button>
|
||||
<button id="btnCallUserInfo" class="ok">调用 UserInfo</button>
|
||||
</div>
|
||||
<div id="userinfoOut" class="muted" style="margin-top:6px;"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label>ID Token(JWT)</label>
|
||||
<textarea id="id_token" class="mono" placeholder="(空)"></textarea>
|
||||
<div class="btns">
|
||||
<button id="btnDecodeJWT">解码显示 Claims</button>
|
||||
</div>
|
||||
<pre id="jwtClaims" class="mono" style="white-space:pre-wrap; word-break:break-all; margin-top:6px;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid2" style="margin-top:12px;">
|
||||
<div>
|
||||
<label>Refresh Token</label>
|
||||
<textarea id="refresh_token" class="mono" placeholder="(空)"></textarea>
|
||||
<div class="btns">
|
||||
<button id="btnRefreshToken">使用 Refresh Token 刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>原始 Token 响应</label>
|
||||
<textarea id="token_raw" class="mono" placeholder="(空)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const toB64Url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
async function sha256B64Url(str){
|
||||
const data = new TextEncoder().encode(str);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return toB64Url(digest);
|
||||
}
|
||||
function randStr(len=64){
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
const arr = new Uint8Array(len); crypto.getRandomValues(arr);
|
||||
return Array.from(arr, v => chars[v % chars.length]).join('');
|
||||
}
|
||||
function setAuthInfo(msg, ok=true){
|
||||
const el = $('authResult');
|
||||
el.textContent = msg;
|
||||
el.className = ok ? 'ok' : 'err';
|
||||
}
|
||||
function qs(name){ const u=new URL(location.href); return u.searchParams.get(name); }
|
||||
|
||||
function persist(name, val){ sessionStorage.setItem('demo_'+name, val); }
|
||||
function load(name){ return sessionStorage.getItem('demo_'+name) || ''; }
|
||||
|
||||
// init defaults
|
||||
(function init(){
|
||||
$('redirect_uri').value = window.location.origin + window.location.pathname;
|
||||
// try load from discovery if issuer saved previously
|
||||
const iss = load('issuer'); if(iss) $('issuer').value = iss;
|
||||
const cid = load('client_id'); if(cid) $('client_id').value = cid;
|
||||
const scp = load('scope'); if(scp) $('scope').value = scp;
|
||||
})();
|
||||
|
||||
$('btnDiscover').onclick = async () => {
|
||||
const iss = $('issuer').value.trim(); if(!iss){ alert('请填写 Issuer'); return; }
|
||||
try{
|
||||
persist('issuer', iss);
|
||||
const res = await fetch(iss.replace(/\/$/,'') + '/api/.well-known/openid-configuration');
|
||||
const d = await res.json();
|
||||
$('authorization_endpoint').value = d.authorization_endpoint || '';
|
||||
$('token_endpoint').value = d.token_endpoint || '';
|
||||
$('userinfo_endpoint').value = d.userinfo_endpoint || '';
|
||||
if (d.issuer) { $('issuer').value = d.issuer; persist('issuer', d.issuer); }
|
||||
$('conf_json').value = JSON.stringify(d, null, 2);
|
||||
setAuthInfo('已从发现文档加载端点', true);
|
||||
}catch(e){ setAuthInfo('自动发现失败:'+e, false); }
|
||||
};
|
||||
|
||||
$('btnGenPkce').onclick = async () => {
|
||||
const v = randStr(64); const c = await sha256B64Url(v);
|
||||
$('code_verifier').value = v; $('code_challenge').value = c;
|
||||
persist('code_verifier', v); persist('code_challenge', c);
|
||||
setAuthInfo('已生成 PKCE 参数', true);
|
||||
};
|
||||
$('btnRandomState').onclick = () => { $('state').value = randStr(16); persist('state', $('state').value); };
|
||||
$('btnRandomNonce').onclick = () => { $('nonce').value = randStr(16); persist('nonce', $('nonce').value); };
|
||||
|
||||
function buildAuthorizeURLFromFields() {
|
||||
const auth = $('authorization_endpoint').value.trim();
|
||||
const token = $('token_endpoint').value.trim(); // just validate
|
||||
const cid = $('client_id').value.trim();
|
||||
const red = $('redirect_uri').value.trim();
|
||||
const scp = $('scope').value.trim() || 'openid profile email';
|
||||
const st = $('state').value.trim() || randStr(16);
|
||||
const no = $('nonce').value.trim() || randStr(16);
|
||||
const cc = $('code_challenge').value.trim();
|
||||
const cv = $('code_verifier').value.trim();
|
||||
if(!auth || !token || !cid || !red){ throw new Error('请先完善端点/ClientID/RedirectURI'); }
|
||||
if(!cc || !cv){ throw new Error('请先生成 PKCE'); }
|
||||
persist('authorization_endpoint', auth); persist('token_endpoint', token);
|
||||
persist('client_id', cid); persist('redirect_uri', red); persist('scope', scp);
|
||||
persist('state', st); persist('nonce', no); persist('code_verifier', cv);
|
||||
const u = new URL(auth);
|
||||
u.searchParams.set('response_type', 'code');
|
||||
u.searchParams.set('client_id', cid);
|
||||
u.searchParams.set('redirect_uri', red);
|
||||
u.searchParams.set('scope', scp);
|
||||
u.searchParams.set('state', st);
|
||||
u.searchParams.set('nonce', no);
|
||||
u.searchParams.set('code_challenge', cc);
|
||||
u.searchParams.set('code_challenge_method', 'S256');
|
||||
return u.toString();
|
||||
}
|
||||
$('btnMakeAuthURL').onclick = () => {
|
||||
try {
|
||||
const url = buildAuthorizeURLFromFields();
|
||||
$('authorize_url').value = url;
|
||||
setAuthInfo('已生成授权链接', true);
|
||||
} catch(e){ setAuthInfo(e.message, false); }
|
||||
};
|
||||
$('btnAuthorize').onclick = () => {
|
||||
try { const url = buildAuthorizeURLFromFields(); location.href = url; }
|
||||
catch(e){ setAuthInfo(e.message, false); }
|
||||
};
|
||||
$('btnCopyAuthURL').onclick = async () => { try{ await navigator.clipboard.writeText($('authorize_url').value); }catch{} };
|
||||
|
||||
// Parse OIDC discovery JSON pasted by user
|
||||
$('btnParseConf').onclick = () => {
|
||||
const txt = $('conf_json').value.trim(); if(!txt){ alert('请先粘贴 JSON'); return; }
|
||||
try{
|
||||
const d = JSON.parse(txt);
|
||||
if (d.issuer) { $('issuer').value = d.issuer; persist('issuer', d.issuer); }
|
||||
if (d.authorization_endpoint) $('authorization_endpoint').value = d.authorization_endpoint;
|
||||
if (d.token_endpoint) $('token_endpoint').value = d.token_endpoint;
|
||||
if (d.userinfo_endpoint) $('userinfo_endpoint').value = d.userinfo_endpoint;
|
||||
setAuthInfo('已解析配置并填充端点', true);
|
||||
}catch(e){ setAuthInfo('解析失败:'+e, false); }
|
||||
};
|
||||
// Generate a minimal discovery JSON from current fields
|
||||
$('btnGenConf').onclick = () => {
|
||||
const d = {
|
||||
issuer: $('issuer').value.trim() || undefined,
|
||||
authorization_endpoint: $('authorization_endpoint').value.trim() || undefined,
|
||||
token_endpoint: $('token_endpoint').value.trim() || undefined,
|
||||
userinfo_endpoint: $('userinfo_endpoint').value.trim() || undefined,
|
||||
};
|
||||
$('conf_json').value = JSON.stringify(d, null, 2);
|
||||
};
|
||||
|
||||
async function postForm(url, data){
|
||||
const body = Object.entries(data).map(([k,v])=> `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
|
||||
const res = await fetch(url, { method:'POST', headers:{ 'Content-Type':'application/x-www-form-urlencoded' }, body });
|
||||
if(!res.ok){ const t = await res.text(); throw new Error(`HTTP ${res.status} ${t}`); }
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function handleCallback(){
|
||||
const code = qs('code'); const err = qs('error');
|
||||
const state = qs('state');
|
||||
if(err){ setAuthInfo('授权失败:'+err, false); return; }
|
||||
if(!code){ setAuthInfo('等待授权...', true); return; }
|
||||
// state check
|
||||
if(state && load('state') && state !== load('state')){ setAuthInfo('state 不匹配,已拒绝', false); return; }
|
||||
try{
|
||||
const tokenEp = load('token_endpoint');
|
||||
const data = await postForm(tokenEp, {
|
||||
grant_type:'authorization_code',
|
||||
code,
|
||||
client_id: load('client_id'),
|
||||
redirect_uri: load('redirect_uri'),
|
||||
code_verifier: load('code_verifier')
|
||||
});
|
||||
$('access_token').value = data.access_token || '';
|
||||
$('id_token').value = data.id_token || '';
|
||||
$('refresh_token').value = data.refresh_token || '';
|
||||
$('token_raw').value = JSON.stringify(data, null, 2);
|
||||
setAuthInfo('授权成功,已获取令牌', true);
|
||||
}catch(e){ setAuthInfo('交换令牌失败:'+e.message, false); }
|
||||
}
|
||||
handleCallback();
|
||||
|
||||
$('btnCopyAT').onclick = async () => { try{ await navigator.clipboard.writeText($('access_token').value); }catch{} };
|
||||
$('btnDecodeJWT').onclick = () => {
|
||||
const t = $('id_token').value.trim(); if(!t){ $('jwtClaims').textContent='(空)'; return; }
|
||||
const parts = t.split('.'); if(parts.length<2){ $('jwtClaims').textContent='格式错误'; return; }
|
||||
try{ const json = JSON.parse(atob(parts[1].replace(/-/g,'+').replace(/_/g,'/'))); $('jwtClaims').textContent = JSON.stringify(json, null, 2);}catch(e){ $('jwtClaims').textContent='解码失败:'+e; }
|
||||
};
|
||||
$('btnCallUserInfo').onclick = async () => {
|
||||
const at = $('access_token').value.trim(); const ep = $('userinfo_endpoint').value.trim(); if(!at||!ep){ alert('请填写UserInfo端点并获取AccessToken'); return; }
|
||||
try{
|
||||
const res = await fetch(ep, { headers:{ Authorization: 'Bearer '+at } });
|
||||
const data = await res.json(); $('userinfoOut').textContent = JSON.stringify(data, null, 2);
|
||||
}catch(e){ $('userinfoOut').textContent = '调用失败:'+e; }
|
||||
};
|
||||
$('btnRefreshToken').onclick = async () => {
|
||||
const rt = $('refresh_token').value.trim(); if(!rt){ alert('没有刷新令牌'); return; }
|
||||
try{
|
||||
const tokenEp = load('token_endpoint');
|
||||
const data = await postForm(tokenEp, {
|
||||
grant_type:'refresh_token',
|
||||
refresh_token: rt,
|
||||
client_id: load('client_id')
|
||||
});
|
||||
$('access_token').value = data.access_token || '';
|
||||
$('id_token').value = data.id_token || '';
|
||||
$('refresh_token').value = data.refresh_token || '';
|
||||
$('token_raw').value = JSON.stringify(data, null, 2);
|
||||
setAuthInfo('刷新成功', true);
|
||||
}catch(e){ setAuthInfo('刷新失败:'+e.message, false); }
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
181
examples/oauth/oauth2_test_client.go
Normal file
181
examples/oauth/oauth2_test_client.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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_dsFyyoyNZWjhbNa2", // 需要先创建客户端
|
||||
ClientSecret: "hLLdn2Ia4UM7hcsJaSuUFDV0Px9BrkNq",
|
||||
TokenURL: "http://localhost:3000/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://localhost:3000/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_dsFyyoyNZWjhbNa2", // 需要先创建客户端
|
||||
ClientSecret: "JHiugKf89OMmTLuZMZyA2sgZnO0Ioae3",
|
||||
RedirectURL: "http://localhost:9999/callback",
|
||||
// 包含 openid/profile/email 以便调用 UserInfo
|
||||
Scopes: []string{"openid", "profile", "email", "api:read"},
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: "http://localhost:3000/api/oauth/authorize",
|
||||
TokenURL: "http://localhost:3000/api/oauth/token",
|
||||
},
|
||||
}
|
||||
|
||||
// 生成PKCE参数
|
||||
codeVerifier := oauth2.GenerateVerifier()
|
||||
state := fmt.Sprintf("state-%d", time.Now().Unix())
|
||||
|
||||
// 构建授权URL
|
||||
url := conf.AuthCodeURL(
|
||||
state,
|
||||
oauth2.S256ChallengeOption(codeVerifier),
|
||||
//oauth2.SetAuthURLParam("audience", "api://new-api"),
|
||||
)
|
||||
|
||||
fmt.Printf("Visit this URL to authorize:\n%s\n\n", url)
|
||||
fmt.Printf("A local server will listen on http://localhost:9999/callback to receive the code...\n")
|
||||
|
||||
// 启动回调本地服务器,自动接收授权码
|
||||
codeCh := make(chan string, 1)
|
||||
srv := &http.Server{Addr: ":9999"}
|
||||
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
if errParam := q.Get("error"); errParam != "" {
|
||||
fmt.Fprintf(w, "Authorization failed: %s", errParam)
|
||||
return
|
||||
}
|
||||
gotState := q.Get("state")
|
||||
if gotState != state {
|
||||
http.Error(w, "state mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
code := q.Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "missing code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "Authorization received. You may close this window.")
|
||||
select {
|
||||
case codeCh <- code:
|
||||
default:
|
||||
}
|
||||
go func() {
|
||||
// 稍后关闭服务
|
||||
_ = srv.Shutdown(context.Background())
|
||||
}()
|
||||
})
|
||||
go func() {
|
||||
_ = srv.ListenAndServe()
|
||||
}()
|
||||
|
||||
// 等待授权码
|
||||
var code string
|
||||
select {
|
||||
case code = <-codeCh:
|
||||
case <-time.After(5 * time.Minute):
|
||||
log.Println("Timeout waiting for authorization code")
|
||||
_ = srv.Shutdown(context.Background())
|
||||
return
|
||||
}
|
||||
|
||||
// 交换令牌
|
||||
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)
|
||||
|
||||
// 使用令牌调用 UserInfo
|
||||
client := conf.Client(context.Background(), token)
|
||||
userInfoURL := buildUserInfoFromAuth(conf.Endpoint.AuthURL)
|
||||
resp, err := client.Get(userInfoURL)
|
||||
if err != nil {
|
||||
log.Printf("UserInfo request failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read UserInfo response: %v", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("UserInfo: %s\n", string(body))
|
||||
}
|
||||
|
||||
// buildUserInfoFromAuth 将授权端点URL转换为UserInfo端点URL
|
||||
func buildUserInfoFromAuth(auth string) string {
|
||||
u, err := url.Parse(auth)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// 将最后一个路径段 authorize 替换为 userinfo
|
||||
dir := path.Dir(u.Path)
|
||||
if strings.HasSuffix(u.Path, "/authorize") {
|
||||
u.Path = path.Join(dir, "userinfo")
|
||||
} else {
|
||||
// 回退:追加默认 /oauth/userinfo
|
||||
u.Path = path.Join(dir, "userinfo")
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
23
go.mod
23
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
|
||||
|
||||
94
go.sum
94
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=
|
||||
|
||||
9
main.go
9
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
|
||||
}
|
||||
@@ -8,11 +8,14 @@ import (
|
||||
"one-api/model"
|
||||
"one-api/setting"
|
||||
"one-api/setting/ratio_setting"
|
||||
"one-api/setting/system_setting"
|
||||
"one-api/src/oauth"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func validUserInfo(username string, role int) bool {
|
||||
@@ -177,6 +180,7 @@ func WssAuth(c *gin.Context) {
|
||||
|
||||
func TokenAuth() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
rawAuth := c.Request.Header.Get("Authorization")
|
||||
// 先检测是否为ws
|
||||
if c.Request.Header.Get("Sec-WebSocket-Protocol") != "" {
|
||||
// Sec-WebSocket-Protocol: realtime, openai-insecure-api-key.sk-xxx, openai-beta.realtime-v1
|
||||
@@ -235,6 +239,11 @@ func TokenAuth() func(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// OAuth Bearer fallback
|
||||
if tryOAuthBearer(c, rawAuth) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -288,6 +297,74 @@ func TokenAuth() func(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// tryOAuthBearer validates an OAuth JWT access token and sets minimal context for relay
|
||||
func tryOAuthBearer(c *gin.Context, rawAuth string) bool {
|
||||
if rawAuth == "" || !strings.HasPrefix(rawAuth, "Bearer ") {
|
||||
return false
|
||||
}
|
||||
tokenString := strings.TrimSpace(strings.TrimPrefix(rawAuth, "Bearer "))
|
||||
if tokenString == "" {
|
||||
return false
|
||||
}
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
// Parse & verify
|
||||
parsed, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, jwt.ErrTokenSignatureInvalid
|
||||
}
|
||||
if kid, ok := t.Header["kid"].(string); ok {
|
||||
if settings.JWTKeyID != "" && kid != settings.JWTKeyID {
|
||||
return nil, jwt.ErrTokenSignatureInvalid
|
||||
}
|
||||
}
|
||||
pub := oauth.GetRSAPublicKey()
|
||||
if pub == nil {
|
||||
return nil, jwt.ErrTokenUnverifiable
|
||||
}
|
||||
return pub, nil
|
||||
})
|
||||
if err != nil || parsed == nil || !parsed.Valid {
|
||||
return false
|
||||
}
|
||||
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// issuer check when configured
|
||||
if iss, ok2 := claims["iss"].(string); !ok2 || (settings.Issuer != "" && iss != settings.Issuer) {
|
||||
return false
|
||||
}
|
||||
// revoke check
|
||||
if jti, ok2 := claims["jti"].(string); ok2 && jti != "" {
|
||||
if revoked, _ := model.IsTokenRevoked(jti); revoked {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// scope check: must contain api:read or api:write or admin
|
||||
scope, _ := claims["scope"].(string)
|
||||
scopePadded := " " + scope + " "
|
||||
if !(strings.Contains(scopePadded, " api:read ") || strings.Contains(scopePadded, " api:write ") || strings.Contains(scopePadded, " admin ")) {
|
||||
return false
|
||||
}
|
||||
// subject must be user id to support quota logic
|
||||
sub, _ := claims["sub"].(string)
|
||||
uid, err := strconv.Atoi(sub)
|
||||
if err != nil || uid <= 0 {
|
||||
return false
|
||||
}
|
||||
// load user cache & set context
|
||||
userCache, err := model.GetUserCache(uid)
|
||||
if err != nil || userCache == nil || userCache.Status != common.UserStatusEnabled {
|
||||
return false
|
||||
}
|
||||
c.Set("id", uid)
|
||||
c.Set("group", userCache.Group)
|
||||
c.Set("user_group", userCache.Group)
|
||||
// set UsingGroup
|
||||
common.SetContextKey(c, constant.ContextKeyUsingGroup, userCache.Group)
|
||||
return true
|
||||
}
|
||||
|
||||
func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) error {
|
||||
if token == nil {
|
||||
return fmt.Errorf("token is nil")
|
||||
|
||||
291
middleware/oauth_jwt.go
Normal file
291
middleware/oauth_jwt.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
"one-api/setting/system_setting"
|
||||
"one-api/src/oauth"
|
||||
"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缓存和刷新机制
|
||||
pub := oauth.GetPublicKeyByKid(kid)
|
||||
if pub == nil {
|
||||
return nil, fmt.Errorf("unknown kid: %s", kid)
|
||||
}
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
// validateOAuthClaims 验证OAuth2 claims
|
||||
func validateOAuthClaims(claims jwt.MapClaims) error {
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
|
||||
// 验证issuer(若配置了 Issuer 则强校验,否则仅要求存在)
|
||||
if iss, ok := claims["iss"].(string); ok {
|
||||
if settings.Issuer != "" && 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")
|
||||
}
|
||||
|
||||
// 检查是否被撤销
|
||||
if jti, ok := claims["jti"].(string); ok && jti != "" {
|
||||
revoked, _ := model.IsTokenRevoked(jti)
|
||||
if revoked {
|
||||
return fmt.Errorf("token revoked")
|
||||
}
|
||||
}
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireOAuthScopeIfPresent enforces scope only when OAuth is present; otherwise no-op
|
||||
func RequireOAuthScopeIfPresent(requiredScope string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !c.GetBool("oauth_authenticated") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
@@ -265,6 +265,7 @@ func migrateDB() error {
|
||||
&Setup{},
|
||||
&TwoFA{},
|
||||
&TwoFABackupCode{},
|
||||
&OAuthClient{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
183
model/oauth_client.go
Normal file
183
model/oauth_client.go
Normal file
@@ -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
|
||||
}
|
||||
57
model/oauth_revoked_token.go
Normal file
57
model/oauth_revoked_token.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"one-api/common"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var revokedMem sync.Map // jti -> exp(unix)
|
||||
|
||||
func RevokeToken(jti string, exp int64) error {
|
||||
if jti == "" {
|
||||
return nil
|
||||
}
|
||||
// Prefer Redis, else in-memory
|
||||
if common.RedisEnabled {
|
||||
ttl := time.Duration(0)
|
||||
if exp > 0 {
|
||||
ttl = time.Until(time.Unix(exp, 0))
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = time.Minute
|
||||
}
|
||||
key := fmt.Sprintf("oauth:revoked:%s", jti)
|
||||
return common.RedisSet(key, "1", ttl)
|
||||
}
|
||||
if exp <= 0 {
|
||||
exp = time.Now().Add(time.Minute).Unix()
|
||||
}
|
||||
revokedMem.Store(jti, exp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsTokenRevoked(jti string) (bool, error) {
|
||||
if jti == "" {
|
||||
return false, nil
|
||||
}
|
||||
if common.RedisEnabled {
|
||||
key := fmt.Sprintf("oauth:revoked:%s", jti)
|
||||
if _, err := common.RedisGet(key); err == nil {
|
||||
return true, nil
|
||||
} else {
|
||||
// Not found or error; treat as not revoked on error to avoid hard failures
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
// In-memory check
|
||||
if v, ok := revokedMem.Load(jti); ok {
|
||||
exp, _ := v.(int64)
|
||||
if exp == 0 || time.Now().Unix() <= exp {
|
||||
return true, nil
|
||||
}
|
||||
revokedMem.Delete(jti)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -31,6 +31,21 @@ 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/openid-configuration", controller.OAuthOIDCConfiguration)
|
||||
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)
|
||||
apiRouter.GET("/oauth/userinfo", middleware.OAuthJWTAuth(), controller.OAuthUserInfo)
|
||||
|
||||
// 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)
|
||||
@@ -40,6 +55,17 @@ func SetApiRouter(router *gin.Engine) {
|
||||
|
||||
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
|
||||
|
||||
// OAuth2 admin operations
|
||||
oauthAdmin := apiRouter.Group("/oauth")
|
||||
oauthAdmin.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.RootAuth())
|
||||
{
|
||||
oauthAdmin.POST("/keys/rotate", controller.RotateOAuthSigningKey)
|
||||
oauthAdmin.GET("/keys", controller.ListOAuthSigningKeys)
|
||||
oauthAdmin.DELETE("/keys/:kid", controller.DeleteOAuthSigningKey)
|
||||
oauthAdmin.POST("/keys/generate_file", controller.GenerateOAuthSigningKeyFile)
|
||||
oauthAdmin.POST("/keys/import_pem", controller.ImportOAuthSigningKey)
|
||||
}
|
||||
|
||||
userRoute := apiRouter.Group("/user")
|
||||
{
|
||||
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
|
||||
@@ -78,7 +104,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
}
|
||||
|
||||
adminRoute := userRoute.Group("/")
|
||||
adminRoute.Use(middleware.AdminAuth())
|
||||
adminRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||
{
|
||||
adminRoute.GET("/", controller.GetAllUsers)
|
||||
adminRoute.GET("/search", controller.SearchUsers)
|
||||
@@ -94,7 +120,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
}
|
||||
}
|
||||
optionRoute := apiRouter.Group("/option")
|
||||
optionRoute.Use(middleware.RootAuth())
|
||||
optionRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.RootAuth())
|
||||
{
|
||||
optionRoute.GET("/", controller.GetOptions)
|
||||
optionRoute.PUT("/", controller.UpdateOption)
|
||||
@@ -108,7 +134,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios)
|
||||
}
|
||||
channelRoute := apiRouter.Group("/channel")
|
||||
channelRoute.Use(middleware.AdminAuth())
|
||||
channelRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||
{
|
||||
channelRoute.GET("/", controller.GetAllChannels)
|
||||
channelRoute.GET("/search", controller.SearchChannels)
|
||||
@@ -159,7 +185,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
}
|
||||
|
||||
redemptionRoute := apiRouter.Group("/redemption")
|
||||
redemptionRoute.Use(middleware.AdminAuth())
|
||||
redemptionRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||
{
|
||||
redemptionRoute.GET("/", controller.GetAllRedemptions)
|
||||
redemptionRoute.GET("/search", controller.SearchRedemptions)
|
||||
@@ -187,13 +213,13 @@ func SetApiRouter(router *gin.Engine) {
|
||||
logRoute.GET("/token", controller.GetLogByKey)
|
||||
}
|
||||
groupRoute := apiRouter.Group("/group")
|
||||
groupRoute.Use(middleware.AdminAuth())
|
||||
groupRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||
{
|
||||
groupRoute.GET("/", controller.GetGroups)
|
||||
}
|
||||
|
||||
prefillGroupRoute := apiRouter.Group("/prefill_group")
|
||||
prefillGroupRoute.Use(middleware.AdminAuth())
|
||||
prefillGroupRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||
{
|
||||
prefillGroupRoute.GET("/", controller.GetPrefillGroups)
|
||||
prefillGroupRoute.POST("/", controller.CreatePrefillGroup)
|
||||
@@ -235,5 +261,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
74
setting/system_setting/oauth2.go
Normal file
74
setting/system_setting/oauth2.go
Normal file
@@ -0,0 +1,74 @@
|
||||
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
|
||||
MaxJWKSKeys int `json:"max_jwks_keys"` // maximum number of JWKS signing keys to retain
|
||||
DefaultPrivateKeyPath string `json:"default_private_key_path"` // suggested private key file path
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
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"},
|
||||
},
|
||||
MaxJWKSKeys: 3,
|
||||
DefaultPrivateKeyPath: "/etc/new-api/oauth2-private.pem",
|
||||
}
|
||||
|
||||
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{}
|
||||
}
|
||||
1069
src/oauth/server.go
Normal file
1069
src/oauth/server.go
Normal file
File diff suppressed because it is too large
Load Diff
82
src/oauth/store.go
Normal file
82
src/oauth/store.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"one-api/common"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// KVStore is a minimal TTL key-value abstraction used by OAuth flows.
|
||||
type KVStore interface {
|
||||
Set(key, value string, ttl time.Duration) error
|
||||
Get(key string) (string, bool)
|
||||
Del(key string) error
|
||||
}
|
||||
|
||||
type redisStore struct{}
|
||||
|
||||
func (r *redisStore) Set(key, value string, ttl time.Duration) error {
|
||||
return common.RedisSet(key, value, ttl)
|
||||
}
|
||||
func (r *redisStore) Get(key string) (string, bool) {
|
||||
v, err := common.RedisGet(key)
|
||||
if err != nil || v == "" {
|
||||
return "", false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
func (r *redisStore) Del(key string) error {
|
||||
return common.RedisDel(key)
|
||||
}
|
||||
|
||||
type memEntry struct {
|
||||
val string
|
||||
exp int64 // unix seconds, 0 means no expiry
|
||||
}
|
||||
|
||||
type memoryStore struct {
|
||||
m sync.Map // key -> memEntry
|
||||
}
|
||||
|
||||
func (m *memoryStore) Set(key, value string, ttl time.Duration) error {
|
||||
var exp int64
|
||||
if ttl > 0 {
|
||||
exp = time.Now().Add(ttl).Unix()
|
||||
}
|
||||
m.m.Store(key, memEntry{val: value, exp: exp})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStore) Get(key string) (string, bool) {
|
||||
v, ok := m.m.Load(key)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
e := v.(memEntry)
|
||||
if e.exp > 0 && time.Now().Unix() > e.exp {
|
||||
m.m.Delete(key)
|
||||
return "", false
|
||||
}
|
||||
return e.val, true
|
||||
}
|
||||
|
||||
func (m *memoryStore) Del(key string) error {
|
||||
m.m.Delete(key)
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
memStore = &memoryStore{}
|
||||
rdsStore = &redisStore{}
|
||||
)
|
||||
|
||||
func getStore() KVStore {
|
||||
if common.RedisEnabled {
|
||||
return rdsStore
|
||||
}
|
||||
return memStore
|
||||
}
|
||||
|
||||
func storeSet(key, val string, ttl time.Duration) error { return getStore().Set(key, val, ttl) }
|
||||
func storeGet(key string) (string, bool) { return getStore().Get(key) }
|
||||
func storeDel(key string) error { return getStore().Del(key) }
|
||||
59
src/oauth/util.go
Normal file
59
src/oauth/util.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// getFormOrBasicAuth extracts client_id/client_secret from Basic Auth first, then form
|
||||
func getFormOrBasicAuth(c *gin.Context) (clientID, clientSecret string) {
|
||||
id, secret, ok := c.Request.BasicAuth()
|
||||
if ok {
|
||||
return strings.TrimSpace(id), strings.TrimSpace(secret)
|
||||
}
|
||||
return strings.TrimSpace(c.PostForm("client_id")), strings.TrimSpace(c.PostForm("client_secret"))
|
||||
}
|
||||
|
||||
// genCode generates URL-safe random string based on nBytes of entropy
|
||||
func genCode(nBytes int) (string, error) {
|
||||
b := make([]byte, nBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// s256Base64URL computes base64url-encoded SHA256 digest
|
||||
func s256Base64URL(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// writeNoStore sets no-store cache headers for OAuth responses
|
||||
func writeNoStore(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Pragma", "no-cache")
|
||||
}
|
||||
|
||||
// writeOAuthRedirectError builds an error redirect to redirect_uri as RFC6749
|
||||
func writeOAuthRedirectError(c *gin.Context, redirectURI, errCode, description, state string) {
|
||||
writeNoStore(c)
|
||||
q := "error=" + url.QueryEscape(errCode)
|
||||
if description != "" {
|
||||
q += "&error_description=" + url.QueryEscape(description)
|
||||
}
|
||||
if state != "" {
|
||||
q += "&state=" + url.QueryEscape(state)
|
||||
}
|
||||
sep := "?"
|
||||
if strings.Contains(redirectURI, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
c.Redirect(http.StatusFound, redirectURI+sep+q)
|
||||
}
|
||||
662
web/public/oauth-demo.html
Normal file
662
web/public/oauth-demo.html
Normal file
@@ -0,0 +1,662 @@
|
||||
<!-- This file is a copy of examples/oauth-demo.html for direct serving under /oauth-demo.html -->
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OAuth2/OIDC 授权码 + PKCE 前端演示</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0c10;
|
||||
--panel: #111317;
|
||||
--muted: #aab2bf;
|
||||
--accent: #3b82f6;
|
||||
--ok: #16a34a;
|
||||
--warn: #f59e0b;
|
||||
--err: #ef4444;
|
||||
--border: #1f2430;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
Segoe UI,
|
||||
Roboto,
|
||||
Helvetica,
|
||||
Arial;
|
||||
background: var(--bg);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 980px;
|
||||
margin: 32px auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.col {
|
||||
flex: 1 1 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
background: #0f1115;
|
||||
color: #e5e7eb;
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
}
|
||||
textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
.btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
button {
|
||||
background: #1a1f2b;
|
||||
color: #e5e7eb;
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
button.ok {
|
||||
background: var(--ok);
|
||||
border-color: var(--ok);
|
||||
color: white;
|
||||
}
|
||||
button.warn {
|
||||
background: var(--warn);
|
||||
border-color: var(--warn);
|
||||
color: black;
|
||||
}
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
.grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 880px) {
|
||||
.grid2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.ok {
|
||||
color: #10b981;
|
||||
}
|
||||
.err {
|
||||
color: #ef4444;
|
||||
}
|
||||
.sep {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>OAuth2/OIDC 授权码 + PKCE 前端演示</h1>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label
|
||||
>Issuer(可选,用于自动发现
|
||||
/.well-known/openid-configuration)</label
|
||||
>
|
||||
<input id="issuer" placeholder="https://your-domain" />
|
||||
<div class="btns">
|
||||
<button class="" id="btnDiscover">自动发现端点</button>
|
||||
</div>
|
||||
<div class="muted">提示:若未配置 Issuer,可直接填写下方端点。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label>Response Type</label>
|
||||
<select id="response_type">
|
||||
<option value="code" selected>code</option>
|
||||
<option value="token">token</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label>Authorization Endpoint</label
|
||||
><input
|
||||
id="authorization_endpoint"
|
||||
placeholder="https://domain/api/oauth/authorize"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label>Token Endpoint</label
|
||||
><input
|
||||
id="token_endpoint"
|
||||
placeholder="https://domain/api/oauth/token"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label>UserInfo Endpoint(可选)</label
|
||||
><input
|
||||
id="userinfo_endpoint"
|
||||
placeholder="https://domain/api/oauth/userinfo"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label>Client ID</label
|
||||
><input id="client_id" placeholder="your-public-client-id" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label>Client Secret(可选,机密客户端)</label
|
||||
><input id="client_secret" placeholder="留空表示公开客户端" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label>Redirect URI(当前页地址或你的回调)</label
|
||||
><input id="redirect_uri" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<label>Scope</label
|
||||
><input id="scope" value="openid profile email" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col"><label>State</label><input id="state" /></div>
|
||||
<div class="col"><label>Nonce</label><input id="nonce" /></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label>Code Verifier(自动生成,不会上送)</label
|
||||
><input id="code_verifier" class="mono" readonly />
|
||||
</div>
|
||||
<div class="col">
|
||||
<label>Code Challenge(S256)</label
|
||||
><input id="code_challenge" class="mono" readonly />
|
||||
</div>
|
||||
</div>
|
||||
<div class="btns">
|
||||
<button id="btnGenPkce">生成 PKCE</button>
|
||||
<button id="btnRandomState">随机 State</button>
|
||||
<button id="btnRandomNonce">随机 Nonce</button>
|
||||
<button id="btnMakeAuthURL">生成授权链接</button>
|
||||
<button id="btnAuthorize" class="primary">跳转授权</button>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 8px">
|
||||
<div class="col">
|
||||
<label>授权链接(只生成不跳转)</label>
|
||||
<textarea
|
||||
id="authorize_url"
|
||||
class="mono"
|
||||
placeholder="(空)"
|
||||
></textarea>
|
||||
<div class="btns">
|
||||
<button id="btnCopyAuthURL">复制链接</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sep"></div>
|
||||
<div class="muted">
|
||||
说明:
|
||||
<ul>
|
||||
<li>
|
||||
本页为纯前端演示,适用于公开客户端(不需要 client_secret)。
|
||||
</li>
|
||||
<li>
|
||||
如跨域调用 Token/UserInfo,需要服务端正确设置 CORS;建议将此 demo
|
||||
部署到同源域名下。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="sep"></div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label
|
||||
>粘贴 OIDC Discovery
|
||||
JSON(/.well-known/openid-configuration)</label
|
||||
>
|
||||
<textarea
|
||||
id="conf_json"
|
||||
class="mono"
|
||||
placeholder='{"issuer":"https://...","authorization_endpoint":"...","token_endpoint":"...","userinfo_endpoint":"..."}'
|
||||
></textarea>
|
||||
<div class="btns">
|
||||
<button id="btnParseConf">解析并填充端点</button>
|
||||
<button id="btnGenConf">用当前端点生成 JSON</button>
|
||||
</div>
|
||||
<div class="muted">
|
||||
可将服务端返回的 OIDC Discovery JSON
|
||||
粘贴到此处,点击“解析并填充端点”。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label>授权结果</label>
|
||||
<div id="authResult" class="muted">等待授权...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid2" style="margin-top: 12px">
|
||||
<div>
|
||||
<label>Access Token</label>
|
||||
<textarea
|
||||
id="access_token"
|
||||
class="mono"
|
||||
placeholder="(空)"
|
||||
></textarea>
|
||||
<div class="btns">
|
||||
<button id="btnCopyAT">复制</button
|
||||
><button id="btnCallUserInfo" class="ok">调用 UserInfo</button>
|
||||
</div>
|
||||
<div id="userinfoOut" class="muted" style="margin-top: 6px"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label>ID Token(JWT)</label>
|
||||
<textarea id="id_token" class="mono" placeholder="(空)"></textarea>
|
||||
<div class="btns">
|
||||
<button id="btnDecodeJWT">解码显示 Claims</button>
|
||||
</div>
|
||||
<pre
|
||||
id="jwtClaims"
|
||||
class="mono"
|
||||
style="
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin-top: 6px;
|
||||
"
|
||||
></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid2" style="margin-top: 12px">
|
||||
<div>
|
||||
<label>Refresh Token</label>
|
||||
<textarea
|
||||
id="refresh_token"
|
||||
class="mono"
|
||||
placeholder="(空)"
|
||||
></textarea>
|
||||
<div class="btns">
|
||||
<button id="btnRefreshToken">使用 Refresh Token 刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>原始 Token 响应</label>
|
||||
<textarea id="token_raw" class="mono" placeholder="(空)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const toB64Url = (buf) =>
|
||||
btoa(String.fromCharCode(...new Uint8Array(buf)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
async function sha256B64Url(str) {
|
||||
const data = new TextEncoder().encode(str);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return toB64Url(digest);
|
||||
}
|
||||
function randStr(len = 64) {
|
||||
const chars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
const arr = new Uint8Array(len);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr, (v) => chars[v % chars.length]).join('');
|
||||
}
|
||||
function setAuthInfo(msg, ok = true) {
|
||||
const el = $('authResult');
|
||||
el.textContent = msg;
|
||||
el.className = ok ? 'ok' : 'err';
|
||||
}
|
||||
function qs(name) {
|
||||
const u = new URL(location.href);
|
||||
return u.searchParams.get(name);
|
||||
}
|
||||
function persist(k, v) {
|
||||
sessionStorage.setItem('demo_' + k, v);
|
||||
}
|
||||
function load(k) {
|
||||
return sessionStorage.getItem('demo_' + k) || '';
|
||||
}
|
||||
(function init() {
|
||||
$('redirect_uri').value =
|
||||
window.location.origin + window.location.pathname;
|
||||
const iss = load('issuer');
|
||||
if (iss) $('issuer').value = iss;
|
||||
const cid = load('client_id');
|
||||
if (cid) $('client_id').value = cid;
|
||||
const scp = load('scope');
|
||||
if (scp) $('scope').value = scp;
|
||||
})();
|
||||
$('btnDiscover').onclick = async () => {
|
||||
const iss = $('issuer').value.trim();
|
||||
if (!iss) {
|
||||
alert('请填写 Issuer');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
persist('issuer', iss);
|
||||
const res = await fetch(
|
||||
iss.replace(/\/$/, '') + '/api/.well-known/openid-configuration',
|
||||
);
|
||||
const d = await res.json();
|
||||
$('authorization_endpoint').value = d.authorization_endpoint || '';
|
||||
$('token_endpoint').value = d.token_endpoint || '';
|
||||
$('userinfo_endpoint').value = d.userinfo_endpoint || '';
|
||||
if (d.issuer) {
|
||||
$('issuer').value = d.issuer;
|
||||
persist('issuer', d.issuer);
|
||||
}
|
||||
$('conf_json').value = JSON.stringify(d, null, 2);
|
||||
setAuthInfo('已从发现文档加载端点', true);
|
||||
} catch (e) {
|
||||
setAuthInfo('自动发现失败:' + e, false);
|
||||
}
|
||||
};
|
||||
$('btnGenPkce').onclick = async () => {
|
||||
const v = randStr(64);
|
||||
const c = await sha256B64Url(v);
|
||||
$('code_verifier').value = v;
|
||||
$('code_challenge').value = c;
|
||||
persist('code_verifier', v);
|
||||
persist('code_challenge', c);
|
||||
setAuthInfo('已生成 PKCE 参数', true);
|
||||
};
|
||||
$('btnRandomState').onclick = () => {
|
||||
$('state').value = randStr(16);
|
||||
persist('state', $('state').value);
|
||||
};
|
||||
$('btnRandomNonce').onclick = () => {
|
||||
$('nonce').value = randStr(16);
|
||||
persist('nonce', $('nonce').value);
|
||||
};
|
||||
function buildAuthorizeURLFromFields() {
|
||||
const auth = $('authorization_endpoint').value.trim();
|
||||
const token = $('token_endpoint').value.trim();
|
||||
const cid = $('client_id').value.trim();
|
||||
const red = $('redirect_uri').value.trim();
|
||||
const scp = $('scope').value.trim() || 'openid profile email';
|
||||
const rt = $('response_type').value;
|
||||
const st = $('state').value.trim() || randStr(16);
|
||||
const no = $('nonce').value.trim() || randStr(16);
|
||||
const cc = $('code_challenge').value.trim();
|
||||
const cv = $('code_verifier').value.trim();
|
||||
if (!auth || !cid || !red) {
|
||||
throw new Error('请先完善端点/ClientID/RedirectURI');
|
||||
}
|
||||
if (rt === 'code' && (!cc || !cv)) {
|
||||
throw new Error('请先生成 PKCE');
|
||||
}
|
||||
persist('authorization_endpoint', auth);
|
||||
persist('token_endpoint', token);
|
||||
persist('client_id', cid);
|
||||
persist('redirect_uri', red);
|
||||
persist('scope', scp);
|
||||
persist('state', st);
|
||||
persist('nonce', no);
|
||||
persist('code_verifier', cv);
|
||||
const u = new URL(auth);
|
||||
u.searchParams.set('response_type', rt);
|
||||
u.searchParams.set('client_id', cid);
|
||||
u.searchParams.set('redirect_uri', red);
|
||||
u.searchParams.set('scope', scp);
|
||||
u.searchParams.set('state', st);
|
||||
if (no) u.searchParams.set('nonce', no);
|
||||
if (rt === 'code') {
|
||||
u.searchParams.set('code_challenge', cc);
|
||||
u.searchParams.set('code_challenge_method', 'S256');
|
||||
}
|
||||
return u.toString();
|
||||
}
|
||||
$('btnMakeAuthURL').onclick = () => {
|
||||
try {
|
||||
const url = buildAuthorizeURLFromFields();
|
||||
$('authorize_url').value = url;
|
||||
setAuthInfo('已生成授权链接', true);
|
||||
} catch (e) {
|
||||
setAuthInfo(e.message, false);
|
||||
}
|
||||
};
|
||||
$('btnAuthorize').onclick = () => {
|
||||
try {
|
||||
const url = buildAuthorizeURLFromFields();
|
||||
location.href = url;
|
||||
} catch (e) {
|
||||
setAuthInfo(e.message, false);
|
||||
}
|
||||
};
|
||||
$('btnCopyAuthURL').onclick = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText($('authorize_url').value);
|
||||
} catch {}
|
||||
};
|
||||
async function postForm(url, data, basic) {
|
||||
const body = Object.entries(data)
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||
.join('&');
|
||||
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||||
if (basic && basic.id && basic.secret) {
|
||||
headers['Authorization'] =
|
||||
'Basic ' + btoa(`${basic.id}:${basic.secret}`);
|
||||
}
|
||||
const res = await fetch(url, { method: 'POST', headers, body });
|
||||
if (!res.ok) {
|
||||
const t = await res.text();
|
||||
throw new Error(`HTTP ${res.status} ${t}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
async function handleCallback() {
|
||||
const frag =
|
||||
location.hash && location.hash.startsWith('#')
|
||||
? new URLSearchParams(location.hash.slice(1))
|
||||
: null;
|
||||
const at = frag ? frag.get('access_token') : null;
|
||||
const err = qs('error') || (frag ? frag.get('error') : null);
|
||||
const state = qs('state') || (frag ? frag.get('state') : null);
|
||||
if (err) {
|
||||
setAuthInfo('授权失败:' + err, false);
|
||||
return;
|
||||
}
|
||||
if (at) {
|
||||
$('access_token').value = at || '';
|
||||
$('token_raw').value = JSON.stringify(
|
||||
{
|
||||
access_token: at,
|
||||
token_type: frag.get('token_type'),
|
||||
expires_in: frag.get('expires_in'),
|
||||
scope: frag.get('scope'),
|
||||
state,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
setAuthInfo('隐式模式已获取 Access Token', true);
|
||||
return;
|
||||
}
|
||||
const code = qs('code');
|
||||
if (!code) {
|
||||
setAuthInfo('等待授权...', true);
|
||||
return;
|
||||
}
|
||||
if (state && load('state') && state !== load('state')) {
|
||||
setAuthInfo('state 不匹配,已拒绝', false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const tokenEp = load('token_endpoint');
|
||||
const cid = load('client_id');
|
||||
const csec = $('client_secret').value.trim();
|
||||
const basic = csec ? { id: cid, secret: csec } : null;
|
||||
const data = await postForm(
|
||||
tokenEp,
|
||||
{
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: cid,
|
||||
redirect_uri: load('redirect_uri'),
|
||||
code_verifier: load('code_verifier'),
|
||||
},
|
||||
basic,
|
||||
);
|
||||
$('access_token').value = data.access_token || '';
|
||||
$('id_token').value = data.id_token || '';
|
||||
$('refresh_token').value = data.refresh_token || '';
|
||||
$('token_raw').value = JSON.stringify(data, null, 2);
|
||||
setAuthInfo('授权成功,已获取令牌', true);
|
||||
} catch (e) {
|
||||
setAuthInfo('交换令牌失败:' + e.message, false);
|
||||
}
|
||||
}
|
||||
handleCallback();
|
||||
$('btnCopyAT').onclick = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText($('access_token').value);
|
||||
} catch {}
|
||||
};
|
||||
$('btnDecodeJWT').onclick = () => {
|
||||
const t = $('id_token').value.trim();
|
||||
if (!t) {
|
||||
$('jwtClaims').textContent = '(空)';
|
||||
return;
|
||||
}
|
||||
const parts = t.split('.');
|
||||
if (parts.length < 2) {
|
||||
$('jwtClaims').textContent = '格式错误';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const json = JSON.parse(
|
||||
atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')),
|
||||
);
|
||||
$('jwtClaims').textContent = JSON.stringify(json, null, 2);
|
||||
} catch (e) {
|
||||
$('jwtClaims').textContent = '解码失败:' + e;
|
||||
}
|
||||
};
|
||||
$('btnCallUserInfo').onclick = async () => {
|
||||
const at = $('access_token').value.trim();
|
||||
const ep = $('userinfo_endpoint').value.trim();
|
||||
if (!at || !ep) {
|
||||
alert('请填写UserInfo端点并获取AccessToken');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(ep, {
|
||||
headers: { Authorization: 'Bearer ' + at },
|
||||
});
|
||||
const data = await res.json();
|
||||
$('userinfoOut').textContent = JSON.stringify(data, null, 2);
|
||||
} catch (e) {
|
||||
$('userinfoOut').textContent = '调用失败:' + e;
|
||||
}
|
||||
};
|
||||
$('btnRefreshToken').onclick = async () => {
|
||||
const rt = $('refresh_token').value.trim();
|
||||
if (!rt) {
|
||||
alert('没有刷新令牌');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const tokenEp = load('token_endpoint');
|
||||
const cid = load('client_id');
|
||||
const csec = $('client_secret').value.trim();
|
||||
const basic = csec ? { id: cid, secret: csec } : null;
|
||||
const data = await postForm(
|
||||
tokenEp,
|
||||
{ grant_type: 'refresh_token', refresh_token: rt, client_id: cid },
|
||||
basic,
|
||||
);
|
||||
$('access_token').value = data.access_token || '';
|
||||
$('id_token').value = data.id_token || '';
|
||||
$('refresh_token').value = data.refresh_token || '';
|
||||
$('token_raw').value = JSON.stringify(data, null, 2);
|
||||
setAuthInfo('刷新成功', true);
|
||||
} catch (e) {
|
||||
setAuthInfo('刷新失败:' + e.message, false);
|
||||
}
|
||||
};
|
||||
$('btnParseConf').onclick = () => {
|
||||
const txt = $('conf_json').value.trim();
|
||||
if (!txt) {
|
||||
alert('请先粘贴 JSON');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const d = JSON.parse(txt);
|
||||
if (d.issuer) {
|
||||
$('issuer').value = d.issuer;
|
||||
persist('issuer', d.issuer);
|
||||
}
|
||||
if (d.authorization_endpoint)
|
||||
$('authorization_endpoint').value = d.authorization_endpoint;
|
||||
if (d.token_endpoint) $('token_endpoint').value = d.token_endpoint;
|
||||
if (d.userinfo_endpoint)
|
||||
$('userinfo_endpoint').value = d.userinfo_endpoint;
|
||||
setAuthInfo('已解析配置并填充端点', true);
|
||||
} catch (e) {
|
||||
setAuthInfo('解析失败:' + e, false);
|
||||
}
|
||||
};
|
||||
$('btnGenConf').onclick = () => {
|
||||
const d = {
|
||||
issuer: $('issuer').value.trim() || undefined,
|
||||
authorization_endpoint:
|
||||
$('authorization_endpoint').value.trim() || undefined,
|
||||
token_endpoint: $('token_endpoint').value.trim() || undefined,
|
||||
userinfo_endpoint: $('userinfo_endpoint').value.trim() || undefined,
|
||||
};
|
||||
$('conf_json').value = JSON.stringify(d, null, 2);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -44,6 +44,7 @@ import Task from './pages/Task';
|
||||
import ModelPage from './pages/Model';
|
||||
import Playground from './pages/Playground';
|
||||
import OAuth2Callback from './components/auth/OAuth2Callback';
|
||||
import OAuthConsent from './pages/OAuth';
|
||||
import PersonalSetting from './components/settings/PersonalSetting';
|
||||
import Setup from './pages/Setup';
|
||||
import SetupCheck from './components/layout/SetupCheck';
|
||||
@@ -198,6 +199,14 @@ function App() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/oauth/consent'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<OAuthConsent />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/oauth/linuxdo'
|
||||
element={
|
||||
|
||||
@@ -176,7 +176,11 @@ const LoginForm = () => {
|
||||
centered: true,
|
||||
});
|
||||
}
|
||||
navigate('/console');
|
||||
// 优先跳回 next(仅允许相对路径)
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
const next = sp.get('next');
|
||||
const isSafeInternalPath = next && next.startsWith('/') && !next.startsWith('//');
|
||||
navigate(isSafeInternalPath ? next : '/console');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -286,7 +290,10 @@ const LoginForm = () => {
|
||||
setUserData(data);
|
||||
updateAPI();
|
||||
showSuccess('登录成功!');
|
||||
navigate('/console');
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
const next = sp.get('next');
|
||||
const isSafeInternalPath = next && next.startsWith('/') && !next.startsWith('//');
|
||||
navigate(isSafeInternalPath ? next : '/console');
|
||||
};
|
||||
|
||||
// 返回登录页面
|
||||
|
||||
@@ -135,7 +135,9 @@ const TwoFactorAuthModal = ({
|
||||
autoFocus
|
||||
/>
|
||||
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
|
||||
{t('支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。')}
|
||||
{t(
|
||||
'支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。',
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
|
||||
import { Copy, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { copy } from '../../helpers';
|
||||
import { copy } from '../../../helpers';
|
||||
|
||||
const PERFORMANCE_CONFIG = {
|
||||
MAX_DISPLAY_LENGTH: 50000, // 最大显示字符数
|
||||
135
web/src/components/common/ui/ResponsiveModal.jsx
Normal file
135
web/src/components/common/ui/ResponsiveModal.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Typography } from '@douyinfe/semi-ui';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
/**
|
||||
* ResponsiveModal 响应式模态框组件
|
||||
*
|
||||
* 特性:
|
||||
* - 响应式布局:移动端和桌面端不同的宽度和布局
|
||||
* - 自定义头部:标题左对齐,操作按钮右对齐,移动端自动换行
|
||||
* - Tailwind CSS 样式支持
|
||||
* - 保持原 Modal 组件的所有功能
|
||||
*/
|
||||
const ResponsiveModal = ({
|
||||
visible,
|
||||
onCancel,
|
||||
title,
|
||||
headerActions = [],
|
||||
children,
|
||||
width = { mobile: '95%', desktop: 600 },
|
||||
className = '',
|
||||
footer = null,
|
||||
titleProps = {},
|
||||
headerClassName = '',
|
||||
actionsClassName = '',
|
||||
...props
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// 自定义 Header 组件
|
||||
const CustomHeader = () => {
|
||||
if (!title && (!headerActions || headerActions.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex w-full gap-3 justify-between ${
|
||||
isMobile ? 'flex-col items-start' : 'flex-row items-center'
|
||||
} ${headerClassName}`}
|
||||
>
|
||||
{title && (
|
||||
<Title heading={5} className='m-0 min-w-fit' {...titleProps}>
|
||||
{title}
|
||||
</Title>
|
||||
)}
|
||||
{headerActions && headerActions.length > 0 && (
|
||||
<div
|
||||
className={`flex flex-wrap gap-2 items-center ${
|
||||
isMobile ? 'w-full justify-start' : 'w-auto justify-end'
|
||||
} ${actionsClassName}`}
|
||||
>
|
||||
{headerActions.map((action, index) => (
|
||||
<React.Fragment key={index}>{action}</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 计算模态框宽度
|
||||
const getModalWidth = () => {
|
||||
if (typeof width === 'object') {
|
||||
return isMobile ? width.mobile : width.desktop;
|
||||
}
|
||||
return width;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={<CustomHeader />}
|
||||
onCancel={onCancel}
|
||||
footer={footer}
|
||||
width={getModalWidth()}
|
||||
className={`!top-12 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
ResponsiveModal.propTypes = {
|
||||
// Modal 基础属性
|
||||
visible: PropTypes.bool.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
children: PropTypes.node,
|
||||
|
||||
// 自定义头部
|
||||
title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
||||
headerActions: PropTypes.arrayOf(PropTypes.node),
|
||||
|
||||
// 样式和布局
|
||||
width: PropTypes.oneOfType([
|
||||
PropTypes.number,
|
||||
PropTypes.string,
|
||||
PropTypes.shape({
|
||||
mobile: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
||||
desktop: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
||||
}),
|
||||
]),
|
||||
className: PropTypes.string,
|
||||
footer: PropTypes.node,
|
||||
|
||||
// 标题自定义属性
|
||||
titleProps: PropTypes.object,
|
||||
|
||||
// 自定义 CSS 类
|
||||
headerClassName: PropTypes.string,
|
||||
actionsClassName: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ResponsiveModal;
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Code, Zap, Clock, X, Eye, Send } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CodeViewer from './CodeViewer';
|
||||
import CodeViewer from '../common/ui/CodeViewer';
|
||||
|
||||
const DebugPanel = ({
|
||||
debugData,
|
||||
|
||||
72
web/src/components/settings/OAuth2Setting.jsx
Normal file
72
web/src/components/settings/OAuth2Setting.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Spin } from '@douyinfe/semi-ui';
|
||||
import { API, showError } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import OAuth2ServerSettings from './oauth2/OAuth2ServerSettings';
|
||||
import OAuth2ClientSettings from './oauth2/OAuth2ClientSettings';
|
||||
|
||||
const OAuth2Setting = () => {
|
||||
const { t } = useTranslation();
|
||||
const [options, setOptions] = useState({});
|
||||
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) {
|
||||
const map = {};
|
||||
for (const item of data) {
|
||||
map[item.key] = item.value;
|
||||
}
|
||||
setOptions(map);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('获取OAuth2设置失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
getOptions();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getOptions();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Spin spinning={loading} size='large'>
|
||||
{/* 服务器配置 */}
|
||||
<OAuth2ServerSettings options={options} refresh={refresh} />
|
||||
|
||||
{/* 客户端管理 */}
|
||||
<OAuth2ClientSettings />
|
||||
</Spin>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuth2Setting;
|
||||
400
web/src/components/settings/oauth2/OAuth2ClientSettings.jsx
Normal file
400
web/src/components/settings/oauth2/OAuth2ClientSettings.jsx
Normal file
@@ -0,0 +1,400 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Empty,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { User } from 'lucide-react';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import OAuth2ClientModal from './modals/OAuth2ClientModal';
|
||||
import SecretDisplayModal from './modals/SecretDisplayModal';
|
||||
import ServerInfoModal from './modals/ServerInfoModal';
|
||||
import JWKSInfoModal from './modals/JWKSInfoModal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = 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 [showModal, setShowModal] = useState(false);
|
||||
const [editingClient, setEditingClient] = useState(null);
|
||||
const [showSecretModal, setShowSecretModal] = useState(false);
|
||||
const [currentSecret, setCurrentSecret] = useState('');
|
||||
const [showServerInfoModal, setShowServerInfoModal] = useState(false);
|
||||
const [showJWKSModal, setShowJWKSModal] = useState(false);
|
||||
|
||||
// 加载客户端列表
|
||||
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(t('加载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(t('删除成功'));
|
||||
loadClients();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('删除失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// 重新生成密钥
|
||||
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(t('重新生成密钥失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// 查看服务器信息
|
||||
const showServerInfo = () => {
|
||||
setShowServerInfoModal(true);
|
||||
};
|
||||
|
||||
// 查看JWKS
|
||||
const showJWKS = () => {
|
||||
setShowJWKSModal(true);
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: t('客户端名称'),
|
||||
dataIndex: 'name',
|
||||
render: (name, record) => (
|
||||
<div className='flex items-center cursor-help'>
|
||||
<User size={16} className='mr-1.5 text-gray-500' />
|
||||
<Tooltip content={record.description || t('暂无描述')} position='top'>
|
||||
<Text strong>{name}</Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('客户端ID'),
|
||||
dataIndex: 'id',
|
||||
render: (id) => (
|
||||
<Text type='tertiary' size='small' code copyable>
|
||||
{id}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
render: (status) => (
|
||||
<Tag color={status === 1 ? 'green' : 'red'} shape='circle'>
|
||||
{status === 1 ? t('启用') : t('禁用')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('类型'),
|
||||
dataIndex: 'client_type',
|
||||
render: (text) => (
|
||||
<Tag color='white' shape='circle'>
|
||||
{text === 'confidential' ? t('机密客户端') : t('公开客户端')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('授权类型'),
|
||||
dataIndex: 'grant_types',
|
||||
render: (grantTypes) => {
|
||||
const types =
|
||||
typeof grantTypes === 'string'
|
||||
? grantTypes.split(',')
|
||||
: grantTypes || [];
|
||||
const typeMap = {
|
||||
client_credentials: t('客户端凭证'),
|
||||
authorization_code: t('授权码'),
|
||||
refresh_token: t('刷新令牌'),
|
||||
};
|
||||
return (
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{types.slice(0, 2).map((type) => (
|
||||
<Tag color='white' key={type} size='small' shape='circle'>
|
||||
{typeMap[type] || type}
|
||||
</Tag>
|
||||
))}
|
||||
{types.length > 2 && (
|
||||
<Tooltip
|
||||
content={types
|
||||
.slice(2)
|
||||
.map((t) => typeMap[t] || t)
|
||||
.join(', ')}
|
||||
>
|
||||
<Tag color='white' size='small' shape='circle'>
|
||||
+{types.length - 2}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('创建时间'),
|
||||
dataIndex: 'created_time',
|
||||
render: (time) => new Date(time * 1000).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
render: (_, record) => (
|
||||
<Space size={4} wrap>
|
||||
<Button
|
||||
type='primary'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setEditingClient(record);
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
{record.client_type === 'confidential' && (
|
||||
<Popconfirm
|
||||
title={t('确认重新生成客户端密钥?')}
|
||||
content={t('操作不可撤销,旧密钥将立即失效。')}
|
||||
onConfirm={() => handleRegenerateSecret(record)}
|
||||
okText={t('确认')}
|
||||
cancelText={t('取消')}
|
||||
position='bottomLeft'
|
||||
>
|
||||
<Button type='secondary' size='small'>
|
||||
{t('重新生成密钥')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={t('请再次确认删除该客户端')}
|
||||
content={t('删除后无法恢复,相关 API 调用将立即失效。')}
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText={t('确定删除')}
|
||||
cancelText={t('取消')}
|
||||
position='bottomLeft'
|
||||
>
|
||||
<Button type='danger' size='small'>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
fixed: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadClients();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className='!rounded-2xl shadow-sm border-0'
|
||||
style={{ marginTop: 10 }}
|
||||
title={
|
||||
<div
|
||||
className='flex flex-col sm:flex-row sm:items-center sm:justify-between w-full gap-3 sm:gap-0'
|
||||
style={{ paddingRight: '8px' }}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<User size={18} className='mr-2' />
|
||||
<Text strong>{t('OAuth2 客户端管理')}</Text>
|
||||
<Tag color='white' shape='circle' size='small' className='ml-2'>
|
||||
{filteredClients.length} {t('个客户端')}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 sm:flex-shrink-0 flex-wrap'>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索客户端名称、ID或描述')}
|
||||
value={searchKeyword}
|
||||
onChange={handleSearch}
|
||||
showClear
|
||||
size='small'
|
||||
style={{ width: 300 }}
|
||||
/>
|
||||
<Button type='tertiary' onClick={loadClients} size='small'>
|
||||
{t('刷新')}
|
||||
</Button>
|
||||
<Button type='secondary' onClick={showServerInfo} size='small'>
|
||||
{t('服务器信息')}
|
||||
</Button>
|
||||
<Button type='secondary' onClick={showJWKS} size='small'>
|
||||
{t('查看JWKS')}
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={() => {
|
||||
setEditingClient(null);
|
||||
setShowModal(true);
|
||||
}}
|
||||
size='small'
|
||||
>
|
||||
{t('创建客户端')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='mb-4'>
|
||||
<Text type='tertiary'>
|
||||
{t(
|
||||
'管理OAuth2客户端应用程序,每个客户端代表一个可以访问API的应用程序。机密客户端用于服务器端应用,公开客户端用于移动应用或单页应用。',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 客户端表格 */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredClients}
|
||||
rowKey='id'
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: true,
|
||||
pageSize: 10,
|
||||
}}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
title={t('暂无OAuth2客户端')}
|
||||
description={t(
|
||||
'还没有创建任何客户端,点击下方按钮创建第一个客户端',
|
||||
)}
|
||||
style={{ padding: 30 }}
|
||||
>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={() => {
|
||||
setEditingClient(null);
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
{t('创建第一个客户端')}
|
||||
</Button>
|
||||
</Empty>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* OAuth2 客户端模态框 */}
|
||||
<OAuth2ClientModal
|
||||
visible={showModal}
|
||||
client={editingClient}
|
||||
onCancel={() => {
|
||||
setShowModal(false);
|
||||
setEditingClient(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setShowModal(false);
|
||||
setEditingClient(null);
|
||||
loadClients();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 密钥显示模态框 */}
|
||||
<SecretDisplayModal
|
||||
visible={showSecretModal}
|
||||
onClose={() => setShowSecretModal(false)}
|
||||
secret={currentSecret}
|
||||
/>
|
||||
|
||||
{/* 服务器信息模态框 */}
|
||||
<ServerInfoModal
|
||||
visible={showServerInfoModal}
|
||||
onClose={() => setShowServerInfoModal(false)}
|
||||
/>
|
||||
|
||||
{/* JWKS信息模态框 */}
|
||||
<JWKSInfoModal
|
||||
visible={showJWKSModal}
|
||||
onClose={() => setShowJWKSModal(false)}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
473
web/src/components/settings/oauth2/OAuth2ServerSettings.jsx
Normal file
473
web/src/components/settings/oauth2/OAuth2ServerSettings.jsx
Normal file
@@ -0,0 +1,473 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Card,
|
||||
Typography,
|
||||
Badge,
|
||||
Divider,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Server } from 'lucide-react';
|
||||
import JWKSManagerModal from './modals/JWKSManagerModal';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
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.allowed_grant_types': [
|
||||
'client_credentials',
|
||||
'authorization_code',
|
||||
'refresh_token',
|
||||
],
|
||||
'oauth2.require_pkce': true,
|
||||
'oauth2.max_jwks_keys': 3,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
const [keysReady, setKeysReady] = useState(true);
|
||||
const [keysLoading, setKeysLoading] = useState(false);
|
||||
const [serverInfo, setServerInfo] = useState(null);
|
||||
const enabledRef = useRef(inputs['oauth2.enabled']);
|
||||
|
||||
// 模态框状态
|
||||
const [jwksVisible, setJwksVisible] = useState(false);
|
||||
|
||||
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 (silent = true) => {
|
||||
// 未启用时不触发测试,避免 404
|
||||
if (!enabledRef.current) return;
|
||||
try {
|
||||
const res = await API.get('/api/oauth/server-info', {
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
if (!enabledRef.current) return;
|
||||
if (
|
||||
res.status === 200 &&
|
||||
(res.data.issuer || res.data.authorization_endpoint)
|
||||
) {
|
||||
if (!silent) showSuccess('OAuth2服务器运行正常');
|
||||
setServerInfo(res.data);
|
||||
} else {
|
||||
if (!enabledRef.current) return;
|
||||
if (!silent) showError('OAuth2服务器测试失败');
|
||||
}
|
||||
} catch (error) {
|
||||
if (!enabledRef.current) return;
|
||||
if (!silent) 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","refresh_token"]',
|
||||
);
|
||||
} catch {
|
||||
currentInputs[key] = [
|
||||
'client_credentials',
|
||||
'authorization_code',
|
||||
'refresh_token',
|
||||
];
|
||||
}
|
||||
} 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]);
|
||||
|
||||
useEffect(() => {
|
||||
enabledRef.current = inputs['oauth2.enabled'];
|
||||
}, [inputs['oauth2.enabled']]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadKeys = async () => {
|
||||
try {
|
||||
setKeysLoading(true);
|
||||
const res = await API.get('/api/oauth/keys', {
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
const list = res?.data?.data || [];
|
||||
setKeysReady(list.length > 0);
|
||||
} catch {
|
||||
setKeysReady(false);
|
||||
} finally {
|
||||
setKeysLoading(false);
|
||||
}
|
||||
};
|
||||
if (inputs['oauth2.enabled']) {
|
||||
loadKeys();
|
||||
testOAuth2(true);
|
||||
} else {
|
||||
// 禁用时清理状态,避免残留状态与不必要的请求
|
||||
setKeysReady(true);
|
||||
setServerInfo(null);
|
||||
setKeysLoading(false);
|
||||
}
|
||||
}, [inputs['oauth2.enabled']]);
|
||||
|
||||
const isEnabled = inputs['oauth2.enabled'];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* OAuth2 服务端管理 */}
|
||||
<Card
|
||||
className='!rounded-2xl shadow-sm border-0'
|
||||
style={{ marginTop: 10 }}
|
||||
title={
|
||||
<div
|
||||
className='flex flex-col sm:flex-row sm:items-center sm:justify-between w-full gap-3 sm:gap-0'
|
||||
style={{ paddingRight: '8px' }}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<Server size={18} className='mr-2' />
|
||||
<Text strong>{t('OAuth2 服务端管理')}</Text>
|
||||
{isEnabled ? (
|
||||
serverInfo ? (
|
||||
<Badge
|
||||
count={t('运行正常')}
|
||||
type='success'
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
) : (
|
||||
<Badge
|
||||
count={t('配置中')}
|
||||
type='warning'
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Badge
|
||||
count={t('未启用')}
|
||||
type='tertiary'
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-2 sm:flex-shrink-0'>
|
||||
{isEnabled && (
|
||||
<Button
|
||||
type='secondary'
|
||||
onClick={() => setJwksVisible(true)}
|
||||
size='small'
|
||||
>
|
||||
{t('密钥管理')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={onSubmit}
|
||||
loading={loading}
|
||||
size='small'
|
||||
>
|
||||
{t('保存配置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
>
|
||||
{!keysReady && isEnabled && (
|
||||
<Banner
|
||||
type='warning'
|
||||
className='!rounded-lg'
|
||||
closeIcon={null}
|
||||
description={t(
|
||||
'尚未准备签名密钥,建议立即初始化或轮换以发布 JWKS。签名密钥用于 JWT 令牌的安全签发。',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Row gutter={[16, 24]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Form.Switch
|
||||
field='oauth2.enabled'
|
||||
label={t('启用 OAuth2 & SSO')}
|
||||
value={inputs['oauth2.enabled']}
|
||||
onChange={handleFieldChange('oauth2.enabled')}
|
||||
extraText={t('开启后将允许以 OAuth2/OIDC 标准进行授权与登录')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Form.Input
|
||||
field='oauth2.issuer'
|
||||
label={t('发行人 (Issuer)')}
|
||||
placeholder={window.location.origin}
|
||||
value={inputs['oauth2.issuer']}
|
||||
onChange={handleFieldChange('oauth2.issuer')}
|
||||
extraText={t('为空则按请求自动推断(含 X-Forwarded-Proto)')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 令牌配置 */}
|
||||
<Divider margin='24px'>{t('令牌配置')}</Divider>
|
||||
|
||||
<Row gutter={[16, 24]}>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Form.InputNumber
|
||||
field='oauth2.access_token_ttl'
|
||||
label={t('访问令牌有效期')}
|
||||
suffix={t('分钟')}
|
||||
min={1}
|
||||
max={1440}
|
||||
value={inputs['oauth2.access_token_ttl']}
|
||||
onChange={handleFieldChange('oauth2.access_token_ttl')}
|
||||
extraText={t('访问令牌的有效时间,建议较短(10-60分钟)')}
|
||||
style={{
|
||||
width: '100%',
|
||||
opacity: isEnabled ? 1 : 0.5,
|
||||
}}
|
||||
disabled={!isEnabled}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Form.InputNumber
|
||||
field='oauth2.refresh_token_ttl'
|
||||
label={t('刷新令牌有效期')}
|
||||
suffix={t('小时')}
|
||||
min={1}
|
||||
max={8760}
|
||||
value={inputs['oauth2.refresh_token_ttl']}
|
||||
onChange={handleFieldChange('oauth2.refresh_token_ttl')}
|
||||
extraText={t('刷新令牌的有效时间,建议较长(12-720小时)')}
|
||||
style={{
|
||||
width: '100%',
|
||||
opacity: isEnabled ? 1 : 0.5,
|
||||
}}
|
||||
disabled={!isEnabled}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Form.InputNumber
|
||||
field='oauth2.max_jwks_keys'
|
||||
label={t('JWKS历史保留上限')}
|
||||
min={1}
|
||||
max={10}
|
||||
value={inputs['oauth2.max_jwks_keys']}
|
||||
onChange={handleFieldChange('oauth2.max_jwks_keys')}
|
||||
extraText={t('轮换后最多保留的历史签名密钥数量')}
|
||||
style={{
|
||||
width: '100%',
|
||||
opacity: isEnabled ? 1 : 0.5,
|
||||
}}
|
||||
disabled={!isEnabled}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 24]} style={{ marginTop: 16 }}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Form.Select
|
||||
field='oauth2.jwt_signing_algorithm'
|
||||
label={t('JWT签名算法')}
|
||||
value={inputs['oauth2.jwt_signing_algorithm']}
|
||||
onChange={handleFieldChange('oauth2.jwt_signing_algorithm')}
|
||||
extraText={t('JWT令牌的签名算法,推荐使用RS256')}
|
||||
style={{
|
||||
width: '100%',
|
||||
opacity: isEnabled ? 1 : 0.5,
|
||||
}}
|
||||
disabled={!isEnabled}
|
||||
>
|
||||
<Form.Select.Option value='RS256'>
|
||||
RS256 (RSA with SHA-256)
|
||||
</Form.Select.Option>
|
||||
<Form.Select.Option value='HS256'>
|
||||
HS256 (HMAC with SHA-256)
|
||||
</Form.Select.Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Form.Input
|
||||
field='oauth2.jwt_key_id'
|
||||
label={t('JWT密钥ID')}
|
||||
placeholder='oauth2-key-1'
|
||||
value={inputs['oauth2.jwt_key_id']}
|
||||
onChange={handleFieldChange('oauth2.jwt_key_id')}
|
||||
extraText={t('用于标识JWT签名密钥,支持密钥轮换')}
|
||||
style={{
|
||||
width: '100%',
|
||||
opacity: isEnabled ? 1 : 0.5,
|
||||
}}
|
||||
disabled={!isEnabled}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 授权配置 */}
|
||||
<Divider margin='24px'>{t('授权配置')}</Divider>
|
||||
|
||||
<Row gutter={[16, 24]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Form.Select
|
||||
field='oauth2.allowed_grant_types'
|
||||
label={t('允许的授权类型')}
|
||||
multiple
|
||||
value={inputs['oauth2.allowed_grant_types']}
|
||||
onChange={handleFieldChange('oauth2.allowed_grant_types')}
|
||||
extraText={t('选择允许的OAuth2授权流程')}
|
||||
style={{
|
||||
width: '100%',
|
||||
opacity: isEnabled ? 1 : 0.5,
|
||||
}}
|
||||
disabled={!isEnabled}
|
||||
>
|
||||
<Form.Select.Option value='client_credentials'>
|
||||
{t('Client Credentials(客户端凭证)')}
|
||||
</Form.Select.Option>
|
||||
<Form.Select.Option value='authorization_code'>
|
||||
{t('Authorization Code(授权码)')}
|
||||
</Form.Select.Option>
|
||||
<Form.Select.Option value='refresh_token'>
|
||||
{t('Refresh Token(刷新令牌)')}
|
||||
</Form.Select.Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Form.Switch
|
||||
field='oauth2.require_pkce'
|
||||
label={t('强制PKCE验证')}
|
||||
value={inputs['oauth2.require_pkce']}
|
||||
onChange={handleFieldChange('oauth2.require_pkce')}
|
||||
extraText={t('为授权码流程强制启用PKCE,提高安全性')}
|
||||
disabled={!isEnabled}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Text type='tertiary' size='small'>
|
||||
<div className='space-y-1'>
|
||||
<div>• {t('OAuth2 服务器提供标准的 API 认证与授权')}</div>
|
||||
<div>
|
||||
•{' '}
|
||||
{t(
|
||||
'支持 Client Credentials、Authorization Code + PKCE 等标准流程',
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
•{' '}
|
||||
{t(
|
||||
'配置保存后多数项即时生效;签名密钥轮换与 JWKS 发布为即时操作',
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
• {t('生产环境务必启用 HTTPS,并妥善管理 JWT 签名密钥')}
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{/* 模态框 */}
|
||||
<JWKSManagerModal
|
||||
visible={jwksVisible}
|
||||
onClose={() => setJwksVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Banner, Typography } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ClientInfoModal = ({ visible, onClose, clientId, clientSecret }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('客户端创建成功')}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
onOk={onClose}
|
||||
cancelText=''
|
||||
okText={t('我已复制保存')}
|
||||
width={650}
|
||||
bodyStyle={{ padding: '20px 24px' }}
|
||||
>
|
||||
<Banner
|
||||
type='success'
|
||||
closeIcon={null}
|
||||
description={t(
|
||||
'客户端信息如下,请立即复制保存。关闭此窗口后将无法再次查看密钥。',
|
||||
)}
|
||||
className='mb-5 !rounded-lg'
|
||||
/>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='flex justify-center items-center'>
|
||||
<div className='text-center'>
|
||||
<Text strong className='block mb-2'>
|
||||
{t('客户端ID')}
|
||||
</Text>
|
||||
<Text code copyable>
|
||||
{clientId}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{clientSecret && (
|
||||
<div className='flex justify-center items-center'>
|
||||
<div className='text-center'>
|
||||
<Text strong className='block mb-2'>
|
||||
{t('客户端密钥(仅此一次显示)')}
|
||||
</Text>
|
||||
<Text code copyable>
|
||||
{clientSecret}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientInfoModal;
|
||||
70
web/src/components/settings/oauth2/modals/JWKSInfoModal.jsx
Normal file
70
web/src/components/settings/oauth2/modals/JWKSInfoModal.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import { API, showError } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CodeViewer from '../../../common/ui/CodeViewer';
|
||||
|
||||
const JWKSInfoModal = ({ visible, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [jwksInfo, setJwksInfo] = useState(null);
|
||||
|
||||
const loadJWKSInfo = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/oauth/jwks');
|
||||
setJwksInfo(res.data);
|
||||
} catch (error) {
|
||||
showError(t('获取JWKS失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
loadJWKSInfo();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('JWKS 信息')}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
onOk={onClose}
|
||||
cancelText=''
|
||||
okText={t('关闭')}
|
||||
width={650}
|
||||
bodyStyle={{ padding: '20px 24px' }}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<CodeViewer
|
||||
content={jwksInfo ? JSON.stringify(jwksInfo, null, 2) : t('加载中...')}
|
||||
title={t('JWKS 密钥集')}
|
||||
language='json'
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default JWKSInfoModal;
|
||||
399
web/src/components/settings/oauth2/modals/JWKSManagerModal.jsx
Normal file
399
web/src/components/settings/oauth2/modals/JWKSManagerModal.jsx
Normal file
@@ -0,0 +1,399 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
Popconfirm,
|
||||
Toast,
|
||||
Form,
|
||||
Card,
|
||||
Tabs,
|
||||
TabPane,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ResponsiveModal from '../../../common/ui/ResponsiveModal';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// 操作模式枚举
|
||||
const OPERATION_MODES = {
|
||||
VIEW: 'view',
|
||||
IMPORT: 'import',
|
||||
GENERATE: 'generate',
|
||||
};
|
||||
|
||||
export default function JWKSManagerModal({ visible, onClose }) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [activeTab, setActiveTab] = useState(OPERATION_MODES.VIEW);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/oauth/keys');
|
||||
if (res?.data?.success) setKeys(res.data.data || []);
|
||||
else showError(res?.data?.message || t('获取密钥列表失败'));
|
||||
} catch {
|
||||
showError(t('获取密钥列表失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rotate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/oauth/keys/rotate', {});
|
||||
if (res?.data?.success) {
|
||||
showSuccess(t('签名密钥已轮换:{{kid}}', { kid: res.data.kid }));
|
||||
await load();
|
||||
} else showError(res?.data?.message || t('密钥轮换失败'));
|
||||
} catch {
|
||||
showError(t('密钥轮换失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const del = async (kid) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.delete(`/api/oauth/keys/${kid}`);
|
||||
if (res?.data?.success) {
|
||||
Toast.success(t('已删除:{{kid}}', { kid }));
|
||||
await load();
|
||||
} else showError(res?.data?.message || t('删除失败'));
|
||||
} catch {
|
||||
showError(t('删除失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Import PEM state
|
||||
const [pem, setPem] = useState('');
|
||||
const [customKid, setCustomKid] = useState('');
|
||||
|
||||
// Generate PEM file state
|
||||
const [genPath, setGenPath] = useState('/etc/new-api/oauth2-private.pem');
|
||||
const [genKid, setGenKid] = useState('');
|
||||
|
||||
// 重置表单数据
|
||||
const resetForms = () => {
|
||||
setPem('');
|
||||
setCustomKid('');
|
||||
setGenKid('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
load();
|
||||
// 重置到主视图
|
||||
setActiveTab(OPERATION_MODES.VIEW);
|
||||
} else {
|
||||
// 模态框关闭时重置表单数据
|
||||
resetForms();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await API.get('/api/oauth/server-info');
|
||||
const p = res?.data?.default_private_key_path;
|
||||
if (p) setGenPath(p);
|
||||
} catch {}
|
||||
})();
|
||||
}, [visible]);
|
||||
|
||||
// 导入 PEM 私钥
|
||||
const importPem = async () => {
|
||||
if (!pem.trim()) return Toast.warning(t('请粘贴 PEM 私钥'));
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/oauth/keys/import_pem', {
|
||||
pem,
|
||||
kid: customKid.trim(),
|
||||
});
|
||||
if (res?.data?.success) {
|
||||
Toast.success(
|
||||
t('已导入私钥并切换到 kid={{kid}}', { kid: res.data.kid }),
|
||||
);
|
||||
resetForms();
|
||||
setActiveTab(OPERATION_MODES.VIEW);
|
||||
await load();
|
||||
} else {
|
||||
Toast.error(res?.data?.message || t('导入失败'));
|
||||
}
|
||||
} catch {
|
||||
Toast.error(t('导入失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 生成 PEM 文件
|
||||
const generatePemFile = async () => {
|
||||
if (!genPath.trim()) return Toast.warning(t('请填写保存路径'));
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/oauth/keys/generate_file', {
|
||||
path: genPath.trim(),
|
||||
kid: genKid.trim(),
|
||||
});
|
||||
if (res?.data?.success) {
|
||||
Toast.success(t('已生成并生效:{{path}}', { path: res.data.path }));
|
||||
resetForms();
|
||||
setActiveTab(OPERATION_MODES.VIEW);
|
||||
await load();
|
||||
} else {
|
||||
Toast.error(res?.data?.message || t('生成失败'));
|
||||
}
|
||||
} catch {
|
||||
Toast.error(t('生成失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'KID',
|
||||
dataIndex: 'kid',
|
||||
render: (kid) => (
|
||||
<Text code copyable>
|
||||
{kid}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('创建时间'),
|
||||
dataIndex: 'created_at',
|
||||
render: (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '-'),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'current',
|
||||
render: (cur) =>
|
||||
cur ? (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('当前')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag shape='circle'>{t('历史')}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
{!r.current && (
|
||||
<Popconfirm
|
||||
title={t('确定删除密钥 {{kid}} ?', { kid: r.kid })}
|
||||
content={t(
|
||||
'删除后使用该 kid 签发的旧令牌仍可被验证(外部 JWKS 缓存可能仍保留)',
|
||||
)}
|
||||
okText={t('删除')}
|
||||
onConfirm={() => del(r.kid)}
|
||||
>
|
||||
<Button size='small' type='danger'>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 头部操作按钮 - 根据当前标签页动态生成
|
||||
const getHeaderActions = () => {
|
||||
if (activeTab === OPERATION_MODES.VIEW) {
|
||||
const hasKeys = Array.isArray(keys) && keys.length > 0;
|
||||
return [
|
||||
<Button key='refresh' onClick={load} loading={loading} size='small'>
|
||||
{t('刷新')}
|
||||
</Button>,
|
||||
<Button
|
||||
key='rotate'
|
||||
type='primary'
|
||||
onClick={rotate}
|
||||
loading={loading}
|
||||
size='small'
|
||||
>
|
||||
{hasKeys ? t('轮换密钥') : t('初始化密钥')}
|
||||
</Button>,
|
||||
];
|
||||
}
|
||||
|
||||
if (activeTab === OPERATION_MODES.IMPORT) {
|
||||
return [
|
||||
<Button
|
||||
key='import'
|
||||
type='primary'
|
||||
onClick={importPem}
|
||||
loading={loading}
|
||||
size='small'
|
||||
>
|
||||
{t('导入并生效')}
|
||||
</Button>,
|
||||
];
|
||||
}
|
||||
|
||||
if (activeTab === OPERATION_MODES.GENERATE) {
|
||||
return [
|
||||
<Button
|
||||
key='generate'
|
||||
type='primary'
|
||||
onClick={generatePemFile}
|
||||
loading={loading}
|
||||
size='small'
|
||||
>
|
||||
{t('生成并生效')}
|
||||
</Button>,
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
// 渲染密钥列表视图
|
||||
const renderKeysView = () => (
|
||||
<Card
|
||||
className='!rounded-lg'
|
||||
title={
|
||||
<Text className='text-blue-700 dark:text-blue-300'>
|
||||
{t(
|
||||
'提示:当前密钥用于签发 JWT 令牌。建议定期轮换密钥以提升安全性。只有历史密钥可以删除。',
|
||||
)}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
dataSource={keys}
|
||||
columns={columns}
|
||||
rowKey='kid'
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
empty={<Text type='tertiary'>{t('暂无密钥')}</Text>}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// 渲染导入 PEM 私钥视图
|
||||
const renderImportView = () => (
|
||||
<Card
|
||||
className='!rounded-lg'
|
||||
title={
|
||||
<Text className='text-yellow-700 dark:text-yellow-300'>
|
||||
{t(
|
||||
'建议:优先使用内存签名密钥与 JWKS 轮换;仅在有合规要求时导入外部私钥。请确保私钥来源可信。',
|
||||
)}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Form labelPosition='left' labelWidth={120}>
|
||||
<Form.Input
|
||||
field='kid'
|
||||
label={t('自定义 KID')}
|
||||
placeholder={t('可留空自动生成')}
|
||||
value={customKid}
|
||||
onChange={setCustomKid}
|
||||
/>
|
||||
<Form.TextArea
|
||||
field='pem'
|
||||
label={t('PEM 私钥')}
|
||||
value={pem}
|
||||
onChange={setPem}
|
||||
rows={8}
|
||||
placeholder={
|
||||
'-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----'
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// 渲染生成 PEM 文件视图
|
||||
const renderGenerateView = () => (
|
||||
<Card
|
||||
className='!rounded-lg'
|
||||
title={
|
||||
<Text className='text-orange-700 dark:text-orange-300'>
|
||||
{t(
|
||||
'建议:仅在合规要求下使用文件私钥。请确保目录权限安全(建议 0600),并妥善备份。',
|
||||
)}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Form labelPosition='left' labelWidth={120}>
|
||||
<Form.Input
|
||||
field='path'
|
||||
label={t('保存路径')}
|
||||
value={genPath}
|
||||
onChange={setGenPath}
|
||||
placeholder='/secure/path/oauth2-private.pem'
|
||||
/>
|
||||
<Form.Input
|
||||
field='genKid'
|
||||
label={t('自定义 KID')}
|
||||
value={genKid}
|
||||
onChange={setGenKid}
|
||||
placeholder={t('可留空自动生成')}
|
||||
/>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
visible={visible}
|
||||
title={t('JWKS 管理')}
|
||||
headerActions={getHeaderActions()}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={{ mobile: '95%', desktop: 800 }}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
type='card'
|
||||
size='medium'
|
||||
className='!-mt-2'
|
||||
>
|
||||
<TabPane tab={t('密钥列表')} itemKey={OPERATION_MODES.VIEW}>
|
||||
{renderKeysView()}
|
||||
</TabPane>
|
||||
<TabPane tab={t('导入 PEM 私钥')} itemKey={OPERATION_MODES.IMPORT}>
|
||||
{renderImportView()}
|
||||
</TabPane>
|
||||
<TabPane tab={t('生成 PEM 文件')} itemKey={OPERATION_MODES.GENERATE}>
|
||||
{renderGenerateView()}
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</ResponsiveModal>
|
||||
);
|
||||
}
|
||||
730
web/src/components/settings/oauth2/modals/OAuth2ClientModal.jsx
Normal file
730
web/src/components/settings/oauth2/modals/OAuth2ClientModal.jsx
Normal file
@@ -0,0 +1,730 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
SideSheet,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
Avatar,
|
||||
Tag,
|
||||
Spin,
|
||||
Radio,
|
||||
Divider,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconKey,
|
||||
IconLink,
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconPlus,
|
||||
IconDelete,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import ClientInfoModal from './ClientInfoModal';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
const AUTH_CODE = 'authorization_code';
|
||||
const CLIENT_CREDENTIALS = 'client_credentials';
|
||||
|
||||
// 子组件:重定向URI编辑卡片
|
||||
function RedirectUriCard({
|
||||
t,
|
||||
isAuthCodeSelected,
|
||||
redirectUris,
|
||||
onAdd,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
onFillTemplate,
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
header={
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='flex items-center'>
|
||||
<Avatar size='small' color='purple' className='mr-2 shadow-md'>
|
||||
<IconLink size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('重定向URI配置')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('用于授权码流程的重定向地址')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={onFillTemplate}
|
||||
size='small'
|
||||
disabled={!isAuthCodeSelected}
|
||||
>
|
||||
{t('填入示例模板')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
headerStyle={{ padding: '12px 16px' }}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
className='!rounded-2xl shadow-sm border-0'
|
||||
>
|
||||
<div className='space-y-1'>
|
||||
{redirectUris.length === 0 && (
|
||||
<div className='text-center py-4 px-4'>
|
||||
<Text type='tertiary' className='text-gray-500 text-sm'>
|
||||
{t('暂无重定向URI,点击下方按钮添加')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{redirectUris.map((uri, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('例如:https://your-app.com/callback')}
|
||||
value={uri}
|
||||
onChange={(value) => onUpdate(index, value)}
|
||||
style={{ flex: 1 }}
|
||||
disabled={!isAuthCodeSelected}
|
||||
/>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
onClick={() => onRemove(index)}
|
||||
disabled={!isAuthCodeSelected}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className='py-2 flex justify-center gap-2'>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={onAdd}
|
||||
disabled={!isAuthCodeSelected}
|
||||
>
|
||||
{t('添加重定向URI')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider margin='12px' align='center'>
|
||||
<Text type='tertiary' size='small'>
|
||||
{isAuthCodeSelected
|
||||
? t(
|
||||
'用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)',
|
||||
)
|
||||
: t('仅在选择“授权码”授权类型时需要配置重定向URI')}
|
||||
</Text>
|
||||
</Divider>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const OAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [redirectUris, setRedirectUris] = useState([]);
|
||||
const [clientType, setClientType] = useState('confidential');
|
||||
const [grantTypes, setGrantTypes] = useState([]);
|
||||
const [allowedGrantTypes, setAllowedGrantTypes] = useState([
|
||||
CLIENT_CREDENTIALS,
|
||||
AUTH_CODE,
|
||||
'refresh_token',
|
||||
]);
|
||||
|
||||
// ClientInfoModal 状态
|
||||
const [showClientInfo, setShowClientInfo] = useState(false);
|
||||
const [clientInfo, setClientInfo] = useState({
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
});
|
||||
|
||||
const isEdit = client?.id !== undefined;
|
||||
const [mode, setMode] = useState('create'); // 'create' | 'edit'
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setMode(isEdit ? 'edit' : 'create');
|
||||
}
|
||||
}, [visible, isEdit]);
|
||||
|
||||
const getInitValues = () => ({
|
||||
name: '',
|
||||
description: '',
|
||||
client_type: 'confidential',
|
||||
grant_types: [],
|
||||
scopes: [],
|
||||
require_pkce: true,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
// 加载后端允许的授权类型
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await API.get('/api/option/');
|
||||
const { success, data } = res.data || {};
|
||||
if (!success || !Array.isArray(data)) return;
|
||||
const found = data.find((i) => i.key === 'oauth2.allowed_grant_types');
|
||||
if (!found) return;
|
||||
let parsed = [];
|
||||
try {
|
||||
parsed = JSON.parse(found.value || '[]');
|
||||
} catch (_) {}
|
||||
if (mounted && Array.isArray(parsed) && parsed.length) {
|
||||
setAllowedGrantTypes(parsed);
|
||||
}
|
||||
} catch (_) {
|
||||
// 忽略错误,使用默认allowedGrantTypes
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setGrantTypes((prev) => {
|
||||
const normalizedPrev = Array.isArray(prev) ? prev : [];
|
||||
// 移除不被允许或与客户端类型冲突的类型
|
||||
let next = normalizedPrev.filter((g) => allowedGrantTypes.includes(g));
|
||||
if (clientType === 'public') {
|
||||
next = next.filter((g) => g !== CLIENT_CREDENTIALS);
|
||||
}
|
||||
return next.length ? next : [];
|
||||
});
|
||||
}, [clientType, allowedGrantTypes]);
|
||||
|
||||
// 初始化表单数据(编辑模式)
|
||||
useEffect(() => {
|
||||
if (client && visible && isEdit) {
|
||||
setLoading(true);
|
||||
// 解析授权类型
|
||||
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;
|
||||
}
|
||||
if (!parsedScopes || parsedScopes.length === 0) {
|
||||
parsedScopes = ['openid', 'profile', 'email', 'api:read'];
|
||||
}
|
||||
|
||||
// 解析重定向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) {}
|
||||
}
|
||||
|
||||
// 过滤不被允许或不兼容的授权类型
|
||||
const filteredGrantTypes = (parsedGrantTypes || []).filter((g) =>
|
||||
allowedGrantTypes.includes(g),
|
||||
);
|
||||
const finalGrantTypes =
|
||||
client.client_type === 'public'
|
||||
? filteredGrantTypes.filter((g) => g !== CLIENT_CREDENTIALS)
|
||||
: filteredGrantTypes;
|
||||
|
||||
setClientType(client.client_type);
|
||||
setGrantTypes(finalGrantTypes);
|
||||
// 不自动新增空白URI,保持与创建模式一致的手动添加体验
|
||||
setRedirectUris(parsedRedirectUris);
|
||||
|
||||
// 设置表单值
|
||||
const formValues = {
|
||||
id: client.id,
|
||||
name: client.name,
|
||||
description: client.description,
|
||||
client_type: client.client_type,
|
||||
grant_types: finalGrantTypes,
|
||||
scopes: parsedScopes,
|
||||
require_pkce: !!client.require_pkce,
|
||||
status: client.status,
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues(formValues);
|
||||
}
|
||||
setLoading(false);
|
||||
}, 100);
|
||||
} else if (visible && !isEdit) {
|
||||
// 创建模式,重置状态
|
||||
setClientType('confidential');
|
||||
setGrantTypes([]);
|
||||
setRedirectUris([]);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues(getInitValues());
|
||||
}
|
||||
}
|
||||
}, [client, visible, isEdit, allowedGrantTypes]);
|
||||
|
||||
const isAuthCodeSelected = grantTypes.includes(AUTH_CODE);
|
||||
const isGrantTypeDisabled = (value) => {
|
||||
if (!allowedGrantTypes.includes(value)) return true;
|
||||
if (clientType === 'public' && value === CLIENT_CREDENTIALS) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
// URL校验:允许 https;http 仅限本地开发域名
|
||||
const isValidRedirectUri = (uri) => {
|
||||
if (!uri || !uri.trim()) return false;
|
||||
try {
|
||||
const u = new URL(uri.trim());
|
||||
if (u.protocol === 'https:') return true;
|
||||
if (u.protocol === 'http:') {
|
||||
const host = u.hostname;
|
||||
return (
|
||||
host === 'localhost' ||
|
||||
host === '127.0.0.1' ||
|
||||
host.endsWith('.local')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理提交
|
||||
const handleSubmit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 过滤空的重定向URI
|
||||
const validRedirectUris = redirectUris
|
||||
.map((u) => (u || '').trim())
|
||||
.filter((u) => u.length > 0);
|
||||
|
||||
// 业务校验
|
||||
if (!grantTypes.length) {
|
||||
showError(t('请至少选择一种授权类型'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验是否包含不被允许的授权类型
|
||||
const invalids = grantTypes.filter((g) => !allowedGrantTypes.includes(g));
|
||||
if (invalids.length) {
|
||||
showError(
|
||||
t('不被允许的授权类型: {{types}}', { types: invalids.join(', ') }),
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientType === 'public' && grantTypes.includes(CLIENT_CREDENTIALS)) {
|
||||
showError(t('公开客户端不允许使用client_credentials授权类型'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (grantTypes.includes(AUTH_CODE)) {
|
||||
if (!validRedirectUris.length) {
|
||||
showError(t('选择授权码授权类型时,必须填写至少一个重定向URI'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const allValid = validRedirectUris.every(isValidRedirectUri);
|
||||
if (!allValid) {
|
||||
showError(t('重定向URI格式不合法:仅支持https,或本地开发使用http'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 避免把 Radio 组件对象形式的 client_type 直接传给后端
|
||||
const { client_type: _formClientType, ...restValues } = values || {};
|
||||
const payload = {
|
||||
...restValues,
|
||||
client_type: clientType,
|
||||
grant_types: grantTypes,
|
||||
redirect_uris: validRedirectUris,
|
||||
};
|
||||
|
||||
let res;
|
||||
if (isEdit) {
|
||||
res = await API.put('/api/oauth_clients/', payload);
|
||||
} else {
|
||||
res = await API.post('/api/oauth_clients/', payload);
|
||||
}
|
||||
|
||||
const { success, message, client_id, client_secret } = res.data;
|
||||
|
||||
if (success) {
|
||||
if (isEdit) {
|
||||
showSuccess(t('OAuth2客户端更新成功'));
|
||||
resetForm();
|
||||
onSuccess();
|
||||
} else {
|
||||
showSuccess(t('OAuth2客户端创建成功'));
|
||||
// 显示客户端信息
|
||||
setClientInfo({
|
||||
clientId: client_id,
|
||||
clientSecret: client_secret,
|
||||
});
|
||||
setShowClientInfo(true);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(isEdit ? t('更新OAuth2客户端失败') : t('创建OAuth2客户端失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.reset();
|
||||
}
|
||||
setClientType('confidential');
|
||||
setGrantTypes([]);
|
||||
setRedirectUris([]);
|
||||
};
|
||||
|
||||
// 处理ClientInfoModal关闭
|
||||
const handleClientInfoClose = () => {
|
||||
setShowClientInfo(false);
|
||||
setClientInfo({ clientId: '', clientSecret: '' });
|
||||
resetForm();
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
// 处理取消
|
||||
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);
|
||||
};
|
||||
|
||||
// 填入示例重定向URI模板
|
||||
const fillRedirectUriTemplate = () => {
|
||||
const template = [
|
||||
'https://your-app.com/auth/callback',
|
||||
'https://localhost:3000/callback',
|
||||
];
|
||||
setRedirectUris(template);
|
||||
};
|
||||
|
||||
// 授权类型变化处理(清理非法项,只设置一次)
|
||||
const handleGrantTypesChange = (values) => {
|
||||
const allowed = Array.isArray(values)
|
||||
? values.filter((v) => allowedGrantTypes.includes(v))
|
||||
: [];
|
||||
const sanitized =
|
||||
clientType === 'public'
|
||||
? allowed.filter((v) => v !== CLIENT_CREDENTIALS)
|
||||
: allowed;
|
||||
setGrantTypes(sanitized);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('grant_types', sanitized);
|
||||
}
|
||||
};
|
||||
|
||||
// 客户端类型变化处理(兼容 RadioGroup 事件对象与直接值)
|
||||
const handleClientTypeChange = (next) => {
|
||||
const value = next && next.target ? next.target.value : next;
|
||||
setClientType(value);
|
||||
// 公开客户端自动移除 client_credentials,并同步表单字段
|
||||
const current = Array.isArray(grantTypes) ? grantTypes : [];
|
||||
const sanitized =
|
||||
value === 'public'
|
||||
? current.filter((g) => g !== CLIENT_CREDENTIALS)
|
||||
: current;
|
||||
if (sanitized !== current) {
|
||||
setGrantTypes(sanitized);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('grant_types', sanitized);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
placement={mode === 'edit' ? 'right' : 'left'}
|
||||
title={
|
||||
<Space>
|
||||
{mode === 'edit' ? (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('编辑')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('创建')}
|
||||
</Tag>
|
||||
)}
|
||||
<Title heading={4} className='m-0'>
|
||||
{mode === 'edit' ? t('编辑OAuth2客户端') : t('创建OAuth2客户端')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={visible}
|
||||
width={isMobile ? '100%' : 700}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
className='!rounded-lg'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{isEdit ? t('保存') : t('创建')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
className='!rounded-lg'
|
||||
type='primary'
|
||||
onClick={handleCancel}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
key={isEdit ? `edit-${client?.id}` : 'create'}
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{() => (
|
||||
<div className='p-2'>
|
||||
{/* 表单内容 */}
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
|
||||
<IconKey size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('设置客户端的基本信息')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isEdit && (
|
||||
<>
|
||||
<Form.Select
|
||||
field='status'
|
||||
label={t('状态')}
|
||||
rules={[{ required: true, message: t('请选择状态') }]}
|
||||
required
|
||||
>
|
||||
<Option value={1}>{t('启用')}</Option>
|
||||
<Option value={2}>{t('禁用')}</Option>
|
||||
</Form.Select>
|
||||
<Form.Input field='id' label={t('客户端ID')} disabled />
|
||||
</>
|
||||
)}
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('客户端名称')}
|
||||
placeholder={t('输入客户端名称')}
|
||||
rules={[{ required: true, message: t('请输入客户端名称') }]}
|
||||
required
|
||||
showClear
|
||||
/>
|
||||
<Form.TextArea
|
||||
field='description'
|
||||
label={t('描述')}
|
||||
placeholder={t('输入客户端描述')}
|
||||
rows={3}
|
||||
showClear
|
||||
/>
|
||||
<Form.RadioGroup
|
||||
label={t('客户端类型')}
|
||||
field='client_type'
|
||||
value={clientType}
|
||||
onChange={handleClientTypeChange}
|
||||
type='card'
|
||||
aria-label={t('选择客户端类型')}
|
||||
disabled={isEdit}
|
||||
rules={[{ required: true, message: t('请选择客户端类型') }]}
|
||||
required
|
||||
>
|
||||
<Radio
|
||||
value='confidential'
|
||||
extra={t('服务器端应用,安全地存储客户端密钥')}
|
||||
style={{ width: isMobile ? '100%' : 'auto' }}
|
||||
>
|
||||
{t('机密客户端(Confidential)')}
|
||||
</Radio>
|
||||
<Radio
|
||||
value='public'
|
||||
extra={t('移动应用或单页应用,无法安全存储密钥')}
|
||||
style={{ width: isMobile ? '100%' : 'auto' }}
|
||||
>
|
||||
{t('公开客户端(Public)')}
|
||||
</Radio>
|
||||
</Form.RadioGroup>
|
||||
<Form.Select
|
||||
field='grant_types'
|
||||
label={t('允许的授权类型')}
|
||||
multiple
|
||||
value={grantTypes}
|
||||
onChange={handleGrantTypesChange}
|
||||
rules={[
|
||||
{ required: true, message: t('请选择至少一种授权类型') },
|
||||
]}
|
||||
required
|
||||
placeholder={t('请选择授权类型(可多选)')}
|
||||
>
|
||||
{clientType !== 'public' && (
|
||||
<Option
|
||||
value={CLIENT_CREDENTIALS}
|
||||
disabled={isGrantTypeDisabled(CLIENT_CREDENTIALS)}
|
||||
>
|
||||
{t('Client Credentials(客户端凭证)')}
|
||||
</Option>
|
||||
)}
|
||||
<Option
|
||||
value={AUTH_CODE}
|
||||
disabled={isGrantTypeDisabled(AUTH_CODE)}
|
||||
>
|
||||
{t('Authorization Code(授权码)')}
|
||||
</Option>
|
||||
<Option
|
||||
value='refresh_token'
|
||||
disabled={isGrantTypeDisabled('refresh_token')}
|
||||
>
|
||||
{t('Refresh Token(刷新令牌)')}
|
||||
</Option>
|
||||
</Form.Select>
|
||||
<Form.Select
|
||||
field='scopes'
|
||||
label={t('允许的权限范围(Scope)')}
|
||||
multiple
|
||||
rules={[
|
||||
{ required: true, message: t('请选择至少一个权限范围') },
|
||||
]}
|
||||
required
|
||||
placeholder={t('请选择权限范围(可多选)')}
|
||||
>
|
||||
<Option value='openid'>{t('openid(OIDC 基础身份)')}</Option>
|
||||
<Option value='profile'>
|
||||
{t('profile(用户名/昵称等)')}
|
||||
</Option>
|
||||
<Option value='email'>{t('email(邮箱信息)')}</Option>
|
||||
<Option value='api:read'>
|
||||
{`api:read (${t('读取API')})`}
|
||||
</Option>
|
||||
<Option value='api:write'>
|
||||
{`api:write (${t('写入API')})`}
|
||||
</Option>
|
||||
<Option value='admin'>{t('admin(管理员权限)')}</Option>
|
||||
</Form.Select>
|
||||
<Form.Switch
|
||||
field='require_pkce'
|
||||
label={t('强制PKCE验证')}
|
||||
size='large'
|
||||
extraText={t(
|
||||
'PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。',
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 重定向URI */}
|
||||
<RedirectUriCard
|
||||
t={t}
|
||||
isAuthCodeSelected={isAuthCodeSelected}
|
||||
redirectUris={redirectUris}
|
||||
onAdd={addRedirectUri}
|
||||
onUpdate={updateRedirectUri}
|
||||
onRemove={removeRedirectUri}
|
||||
onFillTemplate={fillRedirectUriTemplate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
|
||||
{/* 客户端信息展示模态框 */}
|
||||
<ClientInfoModal
|
||||
visible={showClientInfo}
|
||||
onClose={handleClientInfoClose}
|
||||
clientId={clientInfo.clientId}
|
||||
clientSecret={clientInfo.clientSecret}
|
||||
/>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuth2ClientModal;
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Banner, Typography } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SecretDisplayModal = ({ visible, onClose, secret }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('客户端密钥已重新生成')}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
onOk={onClose}
|
||||
cancelText=''
|
||||
okText={t('我已复制保存')}
|
||||
width={650}
|
||||
bodyStyle={{ padding: '20px 24px' }}
|
||||
>
|
||||
<Banner
|
||||
type='success'
|
||||
closeIcon={null}
|
||||
description={t(
|
||||
'新的客户端密钥如下,请立即复制保存。关闭此窗口后将无法再次查看。',
|
||||
)}
|
||||
className='mb-5 !rounded-lg'
|
||||
/>
|
||||
<div className='flex justify-center items-center'>
|
||||
<Text code copyable>
|
||||
{secret}
|
||||
</Text>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecretDisplayModal;
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import { API, showError } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CodeViewer from '../../../common/ui/CodeViewer';
|
||||
|
||||
const ServerInfoModal = ({ visible, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [serverInfo, setServerInfo] = useState(null);
|
||||
|
||||
const loadServerInfo = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/oauth/server-info');
|
||||
setServerInfo(res.data);
|
||||
} catch (error) {
|
||||
showError(t('获取服务器信息失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
loadServerInfo();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('OAuth2 服务器信息')}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
onOk={onClose}
|
||||
cancelText=''
|
||||
okText={t('关闭')}
|
||||
width={650}
|
||||
bodyStyle={{ padding: '20px 24px' }}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<CodeViewer
|
||||
content={
|
||||
serverInfo ? JSON.stringify(serverInfo, null, 2) : t('加载中...')
|
||||
}
|
||||
title={t('OAuth2 服务器配置')}
|
||||
language='json'
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerInfoModal;
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
showSuccess,
|
||||
showError,
|
||||
} from '../../../../helpers';
|
||||
import CodeViewer from '../../../playground/CodeViewer';
|
||||
import CodeViewer from '../../../common/ui/CodeViewer';
|
||||
import { StatusContext } from '../../../../context/Status';
|
||||
import { UserContext } from '../../../../context/User';
|
||||
import { useUserPermissions } from '../../../../hooks/common/useUserPermissions';
|
||||
|
||||
@@ -802,7 +802,9 @@ const EditChannelModal = (props) => {
|
||||
delete localInputs.key;
|
||||
}
|
||||
} else {
|
||||
localInputs.key = batch ? JSON.stringify(keys) : JSON.stringify(keys[0]);
|
||||
localInputs.key = batch
|
||||
? JSON.stringify(keys)
|
||||
: JSON.stringify(keys[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1198,7 +1200,10 @@ const EditChannelModal = (props) => {
|
||||
value={inputs.vertex_key_type || 'json'}
|
||||
onChange={(value) => {
|
||||
// 更新设置中的 vertex_key_type
|
||||
handleChannelOtherSettingsChange('vertex_key_type', value);
|
||||
handleChannelOtherSettingsChange(
|
||||
'vertex_key_type',
|
||||
value,
|
||||
);
|
||||
// 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件
|
||||
if (value === 'api_key') {
|
||||
setBatch(false);
|
||||
@@ -1218,7 +1223,8 @@ const EditChannelModal = (props) => {
|
||||
/>
|
||||
)}
|
||||
{batch ? (
|
||||
inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
inputs.type === 41 &&
|
||||
(inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
<Form.Upload
|
||||
field='vertex_files'
|
||||
label={t('密钥文件 (.json)')}
|
||||
@@ -1282,7 +1288,8 @@ const EditChannelModal = (props) => {
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
{inputs.type === 41 &&
|
||||
(inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
<>
|
||||
{!batch && (
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
Space,
|
||||
Row,
|
||||
Col,
|
||||
Spin, Tooltip
|
||||
Spin,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
|
||||
import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react';
|
||||
@@ -266,7 +267,8 @@ const RechargeCard = ({
|
||||
{payMethods && payMethods.length > 0 ? (
|
||||
<Space wrap>
|
||||
{payMethods.map((payMethod) => {
|
||||
const minTopupVal = Number(payMethod.min_topup) || 0;
|
||||
const minTopupVal =
|
||||
Number(payMethod.min_topup) || 0;
|
||||
const isStripe = payMethod.type === 'stripe';
|
||||
const disabled =
|
||||
(!enableOnlineTopUp && !isStripe) ||
|
||||
@@ -280,7 +282,9 @@ const RechargeCard = ({
|
||||
type='tertiary'
|
||||
onClick={() => preTopUp(payMethod.type)}
|
||||
disabled={disabled}
|
||||
loading={paymentLoading && payWay === payMethod.type}
|
||||
loading={
|
||||
paymentLoading && payWay === payMethod.type
|
||||
}
|
||||
icon={
|
||||
payMethod.type === 'alipay' ? (
|
||||
<SiAlipay size={18} color='#1677FF' />
|
||||
@@ -291,7 +295,10 @@ const RechargeCard = ({
|
||||
) : (
|
||||
<CreditCard
|
||||
size={18}
|
||||
color={payMethod.color || 'var(--semi-color-text-2)'}
|
||||
color={
|
||||
payMethod.color ||
|
||||
'var(--semi-color-text-2)'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -301,12 +308,22 @@ const RechargeCard = ({
|
||||
</Button>
|
||||
);
|
||||
|
||||
return disabled && minTopupVal > Number(topUpCount || 0) ? (
|
||||
<Tooltip content={t('此支付方式最低充值金额为') + ' ' + minTopupVal} key={payMethod.type}>
|
||||
return disabled &&
|
||||
minTopupVal > Number(topUpCount || 0) ? (
|
||||
<Tooltip
|
||||
content={
|
||||
t('此支付方式最低充值金额为') +
|
||||
' ' +
|
||||
minTopupVal
|
||||
}
|
||||
key={payMethod.type}
|
||||
>
|
||||
{buttonEl}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<React.Fragment key={payMethod.type}>{buttonEl}</React.Fragment>
|
||||
<React.Fragment key={payMethod.type}>
|
||||
{buttonEl}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
@@ -324,23 +341,27 @@ const RechargeCard = ({
|
||||
<Form.Slot label={t('选择充值额度')}>
|
||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
|
||||
{presetAmounts.map((preset, index) => {
|
||||
const discount = preset.discount || topupInfo?.discount?.[preset.value] || 1.0;
|
||||
const discount =
|
||||
preset.discount ||
|
||||
topupInfo?.discount?.[preset.value] ||
|
||||
1.0;
|
||||
const originalPrice = preset.value * priceRatio;
|
||||
const discountedPrice = originalPrice * discount;
|
||||
const hasDiscount = discount < 1.0;
|
||||
const actualPay = discountedPrice;
|
||||
const save = originalPrice - discountedPrice;
|
||||
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
border: selectedPreset === preset.value
|
||||
? '2px solid var(--semi-color-primary)'
|
||||
: '1px solid var(--semi-color-border)',
|
||||
border:
|
||||
selectedPreset === preset.value
|
||||
? '2px solid var(--semi-color-primary)'
|
||||
: '1px solid var(--semi-color-border)',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
width: '100%',
|
||||
}}
|
||||
bodyStyle={{ padding: '12px' }}
|
||||
onClick={() => {
|
||||
@@ -352,24 +373,35 @@ const RechargeCard = ({
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Typography.Title heading={6} style={{ margin: '0 0 8px 0' }}>
|
||||
<Typography.Title
|
||||
heading={6}
|
||||
style={{ margin: '0 0 8px 0' }}
|
||||
>
|
||||
<Coins size={18} />
|
||||
{formatLargeNumber(preset.value)}
|
||||
{hasDiscount && (
|
||||
<Tag style={{ marginLeft: 4 }} color="green">
|
||||
{t('折').includes('off') ?
|
||||
((1 - parseFloat(discount)) * 100).toFixed(1) :
|
||||
(discount * 10).toFixed(1)}{t('折')}
|
||||
</Tag>
|
||||
<Tag style={{ marginLeft: 4 }} color='green'>
|
||||
{t('折').includes('off')
|
||||
? (
|
||||
(1 - parseFloat(discount)) *
|
||||
100
|
||||
).toFixed(1)
|
||||
: (discount * 10).toFixed(1)}
|
||||
{t('折')}
|
||||
</Tag>
|
||||
)}
|
||||
</Typography.Title>
|
||||
<div style={{
|
||||
color: 'var(--semi-color-text-2)',
|
||||
fontSize: '12px',
|
||||
margin: '4px 0'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--semi-color-text-2)',
|
||||
fontSize: '12px',
|
||||
margin: '4px 0',
|
||||
}}
|
||||
>
|
||||
{t('实付')} {actualPay.toFixed(2)},
|
||||
{hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`}
|
||||
{hasDiscount
|
||||
? `${t('节省')} ${save.toFixed(2)}`
|
||||
: `${t('节省')} 0.00`}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -80,11 +80,11 @@ const TopUp = () => {
|
||||
// 预设充值额度选项
|
||||
const [presetAmounts, setPresetAmounts] = useState([]);
|
||||
const [selectedPreset, setSelectedPreset] = useState(null);
|
||||
|
||||
|
||||
// 充值配置信息
|
||||
const [topupInfo, setTopupInfo] = useState({
|
||||
amount_options: [],
|
||||
discount: {}
|
||||
discount: {},
|
||||
});
|
||||
|
||||
const topUp = async () => {
|
||||
@@ -262,9 +262,9 @@ const TopUp = () => {
|
||||
if (success) {
|
||||
setTopupInfo({
|
||||
amount_options: data.amount_options || [],
|
||||
discount: data.discount || {}
|
||||
discount: data.discount || {},
|
||||
});
|
||||
|
||||
|
||||
// 处理支付方式
|
||||
let payMethods = data.pay_methods || [];
|
||||
try {
|
||||
@@ -280,10 +280,15 @@ const TopUp = () => {
|
||||
payMethods = payMethods.map((method) => {
|
||||
// 规范化最小充值数
|
||||
const normalizedMinTopup = Number(method.min_topup);
|
||||
method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0;
|
||||
method.min_topup = Number.isFinite(normalizedMinTopup)
|
||||
? normalizedMinTopup
|
||||
: 0;
|
||||
|
||||
// Stripe 的最小充值从后端字段回填
|
||||
if (method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0)) {
|
||||
if (
|
||||
method.type === 'stripe' &&
|
||||
(!method.min_topup || method.min_topup <= 0)
|
||||
) {
|
||||
const stripeMin = Number(data.stripe_min_topup);
|
||||
if (Number.isFinite(stripeMin)) {
|
||||
method.min_topup = stripeMin;
|
||||
@@ -313,7 +318,11 @@ const TopUp = () => {
|
||||
setPayMethods(payMethods);
|
||||
const enableStripeTopUp = data.enable_stripe_topup || false;
|
||||
const enableOnlineTopUp = data.enable_online_topup || false;
|
||||
const minTopUpValue = enableOnlineTopUp? data.min_topup : enableStripeTopUp? data.stripe_min_topup : 1;
|
||||
const minTopUpValue = enableOnlineTopUp
|
||||
? data.min_topup
|
||||
: enableStripeTopUp
|
||||
? data.stripe_min_topup
|
||||
: 1;
|
||||
setEnableOnlineTopUp(enableOnlineTopUp);
|
||||
setEnableStripeTopUp(enableStripeTopUp);
|
||||
setMinTopUp(minTopUpValue);
|
||||
@@ -330,12 +339,12 @@ const TopUp = () => {
|
||||
console.log('解析支付方式失败:', e);
|
||||
setPayMethods([]);
|
||||
}
|
||||
|
||||
|
||||
// 如果有自定义充值数量选项,使用它们替换默认的预设选项
|
||||
if (data.amount_options && data.amount_options.length > 0) {
|
||||
const customPresets = data.amount_options.map(amount => ({
|
||||
const customPresets = data.amount_options.map((amount) => ({
|
||||
value: amount,
|
||||
discount: data.discount[amount] || 1.0
|
||||
discount: data.discount[amount] || 1.0,
|
||||
}));
|
||||
setPresetAmounts(customPresets);
|
||||
}
|
||||
@@ -483,7 +492,7 @@ const TopUp = () => {
|
||||
const selectPresetAmount = (preset) => {
|
||||
setTopUpCount(preset.value);
|
||||
setSelectedPreset(preset.value);
|
||||
|
||||
|
||||
// 计算实际支付金额,考虑折扣
|
||||
const discount = preset.discount || topupInfo.discount[preset.value] || 1.0;
|
||||
const discountedAmount = preset.value * priceRatio * discount;
|
||||
|
||||
@@ -40,9 +40,10 @@ const PaymentConfirmModal = ({
|
||||
amountNumber,
|
||||
discountRate,
|
||||
}) => {
|
||||
const hasDiscount = discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0;
|
||||
const originalAmount = hasDiscount ? (amountNumber / discountRate) : 0;
|
||||
const discountAmount = hasDiscount ? (originalAmount - amountNumber) : 0;
|
||||
const hasDiscount =
|
||||
discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0;
|
||||
const originalAmount = hasDiscount ? amountNumber / discountRate : 0;
|
||||
const discountAmount = hasDiscount ? originalAmount - amountNumber : 0;
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
|
||||
@@ -36,7 +36,11 @@ export const AuthRedirect = ({ children }) => {
|
||||
const user = localStorage.getItem('user');
|
||||
|
||||
if (user) {
|
||||
return <Navigate to='/console' replace />;
|
||||
// 优先使用登录页上的 next 参数(仅允许站内相对路径)
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
const next = sp.get('next');
|
||||
const isSafeInternalPath = next && next.startsWith('/') && !next.startsWith('//');
|
||||
return <Navigate to={isSafeInternalPath ? next : '/console'} replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
|
||||
@@ -444,7 +444,7 @@
|
||||
"其他设置": "Other Settings",
|
||||
"项目仓库地址": "Project Repository Address",
|
||||
"可在设置页面设置关于内容,支持 HTML & Markdown": "The About content can be set on the settings page, supporting HTML & Markdown",
|
||||
"由": "developed by",
|
||||
"由": "by",
|
||||
"开发,基于": "based on",
|
||||
"MIT 协议": "MIT License",
|
||||
"充值额度": "Recharge Quota",
|
||||
@@ -519,6 +519,20 @@
|
||||
"2025年5月10日后添加的渠道,不需要再在部署的时候移除模型名称中的\".\"": "After May 10, 2025, channels added do not need to remove the dot in the model name during deployment",
|
||||
"模型映射必须是合法的 JSON 格式!": "Model mapping must be in valid JSON format!",
|
||||
"取消": "Cancel",
|
||||
"授权": "Authorize",
|
||||
"授权后将重定向到": "You will be redirected to",
|
||||
"域名": "Domain",
|
||||
"请先登录后再继续授权。": "Please log in first to continue authorization.",
|
||||
"暂时无法加载授权信息": "Unable to load authorization information for now",
|
||||
"客户端ID": "Client ID",
|
||||
"公开应用": "Public app",
|
||||
"机密应用": "Confidential app",
|
||||
"授权类型": "Response type",
|
||||
"授权码": "Authorization Code",
|
||||
"未知域": "unknown domain",
|
||||
"想要访问你的": "wants to access your",
|
||||
"切换账户": "Switch account",
|
||||
"加载授权信息中...": "Loading authorization info...",
|
||||
"重置": "Reset",
|
||||
"请输入新的剩余额度": "Please enter the new remaining quota",
|
||||
"请输入单个兑换码中包含的额度": "Please enter the quota included in a single redemption code",
|
||||
@@ -814,7 +828,7 @@
|
||||
"删除所选令牌": "Delete selected token",
|
||||
"请先选择要删除的令牌!": "Please select the token to be deleted!",
|
||||
"已删除 {{count}} 个令牌!": "Deleted {{count}} tokens!",
|
||||
"删除失败": "Delete failed",
|
||||
"删除失败-oauth2clients": "Delete failed",
|
||||
"复制令牌": "Copy token",
|
||||
"请选择你的复制方式": "Please select your copy method",
|
||||
"名称+密钥": "Name + key",
|
||||
@@ -998,7 +1012,7 @@
|
||||
"防失联-定期通知": "Prevent loss of contact - regular notifications",
|
||||
"订阅事件后,当事件触发时,您将会收到相应的通知": "After subscribing to the event, you will receive the corresponding notification when the event is triggered.",
|
||||
"当余额低于 ": "When the balance is lower than",
|
||||
"保存": "save",
|
||||
"保存": "Save",
|
||||
"计费说明": "Billing instructions",
|
||||
"高稳定性": "High stability",
|
||||
"没有账号请先": "If you don't have an account, please",
|
||||
@@ -2084,5 +2098,162 @@
|
||||
"原价": "Original price",
|
||||
"优惠": "Discount",
|
||||
"折": "% off",
|
||||
"节省": "Save"
|
||||
"节省": "Save",
|
||||
"OAuth2 客户端管理": "OAuth2 Clients",
|
||||
"重定向URI配置": "Redirect URI Configuration",
|
||||
"用于授权码流程的重定向地址": "Redirect URIs for authorization code flow",
|
||||
"填入示例模板": "Fill Template Example",
|
||||
"暂无重定向URI,点击下方按钮添加": "No redirect URIs yet. Click the button below to add one.",
|
||||
"例如:https://your-app.com/callback": "e.g.: https://your-app.com/callback",
|
||||
"添加重定向URI": "Add Redirect URI",
|
||||
"用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)": "After authorization, the user will be redirected to these URIs. HTTPS is required (HTTP is allowed only for localhost/127.0.0.1 during local development).",
|
||||
"仅在选择“授权码”授权类型时需要配置重定向URI": "Redirect URIs are only required when Authorization Code is selected.",
|
||||
"请至少选择一种授权类型": "Please select at least one grant type",
|
||||
"不被允许的授权类型: {{types}}": "Disallowed grant types: {{types}}",
|
||||
"公开客户端不允许使用client_credentials授权类型": "Public clients cannot use the client_credentials grant type.",
|
||||
"选择授权码授权类型时,必须填写至少一个重定向URI": "At least one Redirect URI is required when Authorization Code is selected.",
|
||||
"重定向URI格式不合法:仅支持https,或本地开发使用http": "Invalid Redirect URI: only HTTPS is supported, or HTTP for local development.",
|
||||
"OAuth2客户端更新成功": "OAuth2 client updated successfully",
|
||||
"OAuth2客户端创建成功": "OAuth2 client created successfully",
|
||||
"客户端创建成功": "Client Created Successfully",
|
||||
"请妥善保存以下信息:": "Please keep the following information secure:",
|
||||
"客户端信息如下,请立即复制保存。关闭此窗口后将无法再次查看密钥。": "Client information is shown below. Please copy and save immediately. The secret will not be viewable again after closing this window.",
|
||||
"客户端密钥(仅此一次显示)": "Client Secret (shown only once)",
|
||||
"客户端密钥仅显示一次,请立即复制保存。": "The client secret is shown only once. Please copy and save it immediately.",
|
||||
"公开客户端无需密钥。": "Public clients do not require a client secret.",
|
||||
"更新OAuth2客户端失败": "Failed to update OAuth2 client",
|
||||
"创建OAuth2客户端失败": "Failed to create OAuth2 client",
|
||||
"创建": "Create",
|
||||
"编辑OAuth2客户端": "Edit OAuth2 Client",
|
||||
"创建OAuth2客户端": "Create OAuth2 Client",
|
||||
"设置客户端的基本信息": "Set the client's basic information",
|
||||
"输入客户端名称": "Enter client name",
|
||||
"请输入客户端名称": "Please enter the client name",
|
||||
"输入客户端描述": "Enter client description",
|
||||
"客户端类型": "Client Type",
|
||||
"选择客户端类型": "Select client type",
|
||||
"请选择客户端类型": "Please select client type",
|
||||
"服务器端应用,安全地存储客户端密钥": "Server-side app, can securely store the client secret",
|
||||
"机密客户端(Confidential)": "Confidential Client",
|
||||
"移动应用或单页应用,无法安全存储密钥": "Mobile or single-page app, cannot securely store a secret",
|
||||
"公开客户端(Public)": "Public Client",
|
||||
"请选择授权类型(可多选)": "Select grant types (multiple)",
|
||||
"请选择至少一个权限范围": "Please select at least one scope",
|
||||
"请选择权限范围(可多选)": "Select scopes (multiple)",
|
||||
"openid(OIDC 基础身份)": "openid (OIDC basic identity)",
|
||||
"profile(用户名/昵称等)": "profile (username/nickname, etc.)",
|
||||
"email(邮箱信息)": "email (email information)",
|
||||
"读取API": "read API",
|
||||
"写入API": "write API",
|
||||
"admin(管理员权限)": "admin (administrator permission)",
|
||||
"PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。": "PKCE (Proof Key for Code Exchange) improves the security of the authorization code flow.",
|
||||
"请选择状态": "Please select status",
|
||||
"加载OAuth2客户端失败": "Failed to load OAuth2 clients",
|
||||
"删除成功": "Deleted successfully",
|
||||
"删除失败": "Delete failed",
|
||||
"重新生成密钥失败": "Failed to regenerate secret",
|
||||
"OAuth2 服务器信息": "OAuth2 Server Info",
|
||||
"授权服务器配置": "Authorization server configuration",
|
||||
"获取服务器信息失败": "Failed to get server info",
|
||||
"JWKS 信息": "JWKS Info",
|
||||
"JSON Web Key Set": "JSON Web Key Set",
|
||||
"获取JWKS失败": "Failed to get JWKS",
|
||||
"客户端信息": "Client Info",
|
||||
"机密客户端": "Confidential",
|
||||
"公开客户端": "Public",
|
||||
"客户端凭证": "Client Credentials",
|
||||
"刷新令牌": "Refresh Token",
|
||||
"编辑客户端": "Edit Client",
|
||||
"确认重新生成客户端密钥?": "Confirm regenerating client secret?",
|
||||
"客户端": "Client",
|
||||
"操作不可撤销,旧密钥将立即失效。": "This action cannot be undone. The old secret will be invalid immediately.",
|
||||
"确认": "Confirm",
|
||||
"重新生成密钥": "Regenerate Secret",
|
||||
"请再次确认删除该客户端": "Please confirm deleting this client",
|
||||
"删除后无法恢复,相关 API 调用将立即失效。": "This operation cannot be undone. Related API calls will stop working immediately.",
|
||||
"确定删除": "Confirm Delete",
|
||||
"删除客户端": "Delete Client",
|
||||
"管理OAuth2客户端应用程序,每个客户端代表一个可以访问API的应用程序。机密客户端用于服务器端应用,公开客户端用于移动应用或单页应用。": "Manage OAuth2 client applications. Each client represents an application that can access APIs. Confidential clients are for server-side apps; public clients are for mobile apps or SPAs.",
|
||||
"搜索客户端名称、ID或描述": "Search client name, ID or description",
|
||||
"服务器信息": "Server Info",
|
||||
"查看JWKS": "View JWKS",
|
||||
"创建客户端": "Create Client",
|
||||
"第 {{start}}-{{end}} 条,共 {{total}} 条": "Items {{start}}-{{end}} of {{total}}",
|
||||
"暂无OAuth2客户端": "No OAuth2 clients",
|
||||
"还没有创建任何客户端,点击下方按钮创建第一个客户端": "No clients yet. Click the button below to create the first one.",
|
||||
"创建第一个客户端": "Create first client",
|
||||
"客户端密钥已重新生成": "Client secret regenerated",
|
||||
"我已复制保存": "I have copied and saved",
|
||||
"新的客户端密钥如下,请立即复制保存。关闭此窗口后将无法再次查看。": "The new client secret is shown below. Copy and save it now. You will not be able to view it again after closing this window.",
|
||||
"OAuth2 服务端管理": "OAuth2 Server",
|
||||
"运行正常": "Healthy",
|
||||
"配置中": "Configuring",
|
||||
"保存配置": "Save Configuration",
|
||||
"密钥管理": "Key Management",
|
||||
"打开密钥管理": "Open Key Management",
|
||||
"尚未准备签名密钥,建议立即初始化或轮换以发布 JWKS。签名密钥用于 JWT 令牌的安全签发。": "Signing key not prepared. Initialize or rotate to publish JWKS. Signing keys are used to securely issue JWT tokens.",
|
||||
"启用 OAuth2 & SSO": "Enable OAuth2 & SSO",
|
||||
"开启后将允许以 OAuth2/OIDC 标准进行授权与登录": "Enables OAuth2/OIDC standard based authorization and login",
|
||||
"发行人 (Issuer)": "Issuer",
|
||||
"为空则按请求自动推断(含 X-Forwarded-Proto)": "Leave empty to infer from request (including X-Forwarded-Proto)",
|
||||
"令牌配置": "Token Settings",
|
||||
"访问令牌有效期": "Access token TTL",
|
||||
"访问令牌的有效时间,建议较短(10-60分钟)": "Access token lifetime. Recommended: short (10–60 minutes)",
|
||||
"刷新令牌有效期": "Refresh token TTL",
|
||||
"刷新令牌的有效时间,建议较长(12-720小时)": "Refresh token lifetime. Recommended: long (12–720 hours)",
|
||||
"JWKS历史保留上限": "JWKS history retention limit",
|
||||
"轮换后最多保留的历史签名密钥数量": "Max number of historical signing keys to keep after rotation",
|
||||
"JWT签名算法": "JWT signing algorithm",
|
||||
"JWT令牌的签名算法,推荐使用RS256": "Signing algorithm for JWT tokens. RS256 is recommended",
|
||||
"JWT密钥ID": "JWT key ID",
|
||||
"用于标识JWT签名密钥,支持密钥轮换": "Identifier for JWT signing key; supports key rotation",
|
||||
"授权配置": "Authorization Settings",
|
||||
"允许的授权类型": "Allowed grant types",
|
||||
"允许的权限范围(Scope)": "Allowed scopes",
|
||||
"选择允许的OAuth2授权流程": "Select allowed OAuth2 grant flows",
|
||||
"Client Credentials(客户端凭证)": "Client Credentials",
|
||||
"Authorization Code(授权码)": "Authorization Code",
|
||||
"Refresh Token(刷新令牌)": "Refresh Token",
|
||||
"强制PKCE验证": "Enforce PKCE",
|
||||
"为授权码流程强制启用PKCE,提高安全性": "Enforce PKCE for authorization code flow to improve security",
|
||||
"OAuth2 服务器提供标准的 API 认证与授权": "OAuth2 server provides standard API authentication and authorization",
|
||||
"支持 Client Credentials、Authorization Code + PKCE 等标准流程": "Supports standard flows such as Client Credentials and Authorization Code + PKCE",
|
||||
"配置保存后多数项即时生效;签名密钥轮换与 JWKS 发布为即时操作": "Most settings take effect immediately after saving; key rotation and JWKS publication are instantaneous",
|
||||
"生产环境务必启用 HTTPS,并妥善管理 JWT 签名密钥": "Enable HTTPS in production and manage JWT signing keys properly",
|
||||
"个客户端": "clients",
|
||||
"请妥善保管此密钥,用于应用程序的身份验证": "Please keep this secret safe, it is used for application authentication",
|
||||
"客户端名称": "Client Name",
|
||||
"暂无描述": "No description",
|
||||
"OAuth2 服务器配置": "OAuth2 Server Configuration",
|
||||
"JWKS 密钥集": "JWKS Key Set",
|
||||
"获取密钥列表失败": "Failed to fetch key list",
|
||||
"签名密钥已轮换:{{kid}}": "Signing key rotated: {{kid}}",
|
||||
"密钥轮换失败": "Key rotation failed",
|
||||
"已删除:{{kid}}": "Deleted: {{kid}}",
|
||||
"请粘贴 PEM 私钥": "Please paste PEM private key",
|
||||
"已导入私钥并切换到 kid={{kid}}": "Private key imported and switched to kid={{kid}}",
|
||||
"导入失败": "Import failed",
|
||||
"请填写保存路径": "Please enter the save path",
|
||||
"已生成并生效:{{path}}": "Generated and applied: {{path}}",
|
||||
"生成失败": "Generation failed",
|
||||
"当前": "Current",
|
||||
"历史": "History",
|
||||
"确定删除密钥 {{kid}} ?": "Confirm delete key {{kid}}?",
|
||||
"删除后使用该 kid 签发的旧令牌仍可被验证(外部 JWKS 缓存可能仍保留)": "Tokens issued with this kid may still be verifiable (external JWKS caches may retain the key)",
|
||||
"轮换密钥": "Rotate key",
|
||||
"初始化密钥": "Initialize key",
|
||||
"导入并生效": "Import and apply",
|
||||
"生成并生效": "Generate and apply",
|
||||
"提示:当前密钥用于签发 JWT 令牌。建议定期轮换密钥以提升安全性。只有历史密钥可以删除。": "Tip: The current key is used to sign JWT tokens. Rotate keys regularly for security. Only historical keys can be deleted.",
|
||||
"暂无密钥": "No keys yet",
|
||||
"建议:优先使用内存签名密钥与 JWKS 轮换;仅在有合规要求时导入外部私钥。请确保私钥来源可信。": "Recommendation: Prefer in-memory signing keys with JWKS rotation; import external private keys only when required for compliance. Ensure the private key source is trusted.",
|
||||
"自定义 KID": "Custom KID",
|
||||
"可留空自动生成": "Optional, auto-generate if empty",
|
||||
"PEM 私钥": "PEM private key",
|
||||
"建议:仅在合规要求下使用文件私钥。请确保目录权限安全(建议 0600),并妥善备份。": "Recommendation: Use file-based private keys only when required for compliance. Ensure directory permissions are secure (0600 recommended) and back up properly.",
|
||||
"保存路径": "Save path",
|
||||
"JWKS 管理": "JWKS Management",
|
||||
"密钥列表": "Key list",
|
||||
"导入 PEM 私钥": "Import PEM private key",
|
||||
"生成 PEM 文件": "Generate PEM file"
|
||||
}
|
||||
|
||||
391
web/src/pages/OAuth/index.jsx
Normal file
391
web/src/pages/OAuth/index.jsx
Normal file
@@ -0,0 +1,391 @@
|
||||
/*
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Typography,
|
||||
Spin,
|
||||
Banner,
|
||||
Avatar,
|
||||
Divider,
|
||||
Popover,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Link, Dot, Key, User, Mail, Eye, Pencil, Shield } from 'lucide-react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, getLogo } from '../../helpers';
|
||||
import { stringToColor } from '../../helpers/render';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
function useQuery() {
|
||||
const { search } = useLocation();
|
||||
return useMemo(() => new URLSearchParams(search), [search]);
|
||||
}
|
||||
|
||||
// 获取scope对应的图标
|
||||
function getScopeIcon(scopeName) {
|
||||
switch (scopeName) {
|
||||
case 'openid':
|
||||
return Key;
|
||||
case 'profile':
|
||||
return User;
|
||||
case 'email':
|
||||
return Mail;
|
||||
case 'api:read':
|
||||
return Eye;
|
||||
case 'api:write':
|
||||
return Pencil;
|
||||
case 'admin':
|
||||
return Shield;
|
||||
default:
|
||||
return Dot;
|
||||
}
|
||||
}
|
||||
|
||||
// 权限项组件
|
||||
function ScopeItem({ name, description }) {
|
||||
const Icon = getScopeIcon(name);
|
||||
|
||||
return (
|
||||
<div className='flex items-start gap-3 py-2'>
|
||||
<div className='w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5'>
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<Text strong className='block'>
|
||||
{name}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text type='tertiary' size='small' className='block mt-1'>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OAuthConsent() {
|
||||
const { t } = useTranslation();
|
||||
const query = useQuery();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [info, setInfo] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const params = useMemo(() => {
|
||||
const allowed = [
|
||||
'response_type',
|
||||
'client_id',
|
||||
'redirect_uri',
|
||||
'scope',
|
||||
'state',
|
||||
'code_challenge',
|
||||
'code_challenge_method',
|
||||
'nonce',
|
||||
];
|
||||
const obj = {};
|
||||
allowed.forEach((k) => {
|
||||
const v = query.get(k);
|
||||
if (v) obj[k] = v;
|
||||
});
|
||||
if (!obj.response_type) obj.response_type = 'code';
|
||||
return obj;
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/oauth/authorize', {
|
||||
params: { ...params, mode: 'prepare' },
|
||||
// skip error toast, we'll handle gracefully
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
setInfo(res.data);
|
||||
setError('');
|
||||
} catch (e) {
|
||||
// 401 login required or other error
|
||||
setError(e?.response?.data?.error || 'failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [params]);
|
||||
|
||||
const handleAction = (action) => {
|
||||
const u = new URL(window.location.origin + '/api/oauth/authorize');
|
||||
Object.entries(params).forEach(([k, v]) => u.searchParams.set(k, v));
|
||||
u.searchParams.set(action, '1');
|
||||
window.location.href = u.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='min-h-screen flex items-center justify-center px-3 sm:px-4 py-16 sm:py-20'>
|
||||
<div className='w-full max-w-sm sm:max-w-lg'>
|
||||
{loading ? (
|
||||
<Card className='text-center py-8'>
|
||||
<Spin size='large' />
|
||||
<Text type='tertiary' className='block mt-4'>
|
||||
{t('加载授权信息中...')}
|
||||
</Text>
|
||||
</Card>
|
||||
) : error ? (
|
||||
<Banner
|
||||
type='warning'
|
||||
closeIcon={null}
|
||||
className='!rounded-lg'
|
||||
description={
|
||||
error === 'login_required'
|
||||
? t('请先登录后再继续授权。')
|
||||
: t('暂时无法加载授权信息')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
info && (
|
||||
<>
|
||||
<Card
|
||||
className='!rounded-2xl border-0'
|
||||
footer={
|
||||
<div className='space-y-3 px-2 sm:px-0'>
|
||||
<div className='flex flex-col sm:flex-row gap-2'>
|
||||
<Button
|
||||
theme='outline'
|
||||
onClick={() => handleAction('deny')}
|
||||
className='w-full'
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
onClick={() => handleAction('approve')}
|
||||
className='w-full'
|
||||
>
|
||||
{t('授权')} {info?.user?.name || t('用户')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<Text type='tertiary' size='small' className='block'>
|
||||
{t('授权后将重定向到')}
|
||||
</Text>
|
||||
<Text type='tertiary' size='small' className='block'>
|
||||
{info?.redirect_uri?.length > 60
|
||||
? info.redirect_uri.slice(0, 60) + '...'
|
||||
: info?.redirect_uri}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* 头部:应用 → 链接 → 站点Logo */}
|
||||
<div className='text-center py-6 sm:py-8 px-3 sm:px-0'>
|
||||
<div className='flex items-center justify-center gap-4 sm:gap-6 mb-4 sm:mb-6'>
|
||||
{/* 应用图标 */}
|
||||
<Popover
|
||||
content={
|
||||
<div className='max-w-xs p-2'>
|
||||
<Text strong className='block text-sm mb-1'>
|
||||
{info?.client?.name || info?.client?.id}
|
||||
</Text>
|
||||
{info?.client?.desc && (
|
||||
<Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='block'
|
||||
>
|
||||
{info.client.desc}
|
||||
</Text>
|
||||
)}
|
||||
{info?.client?.domain && (
|
||||
<Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='block mt-1'
|
||||
>
|
||||
{t('域名')}: {info.client.domain}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
trigger='hover'
|
||||
position='top'
|
||||
>
|
||||
<Avatar
|
||||
size={36}
|
||||
style={{
|
||||
backgroundColor: stringToColor(
|
||||
info?.client?.name || info?.client?.id || 'A',
|
||||
),
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{String(info?.client?.name || info?.client?.id || 'A')
|
||||
.slice(0, 1)
|
||||
.toUpperCase()}
|
||||
</Avatar>
|
||||
</Popover>
|
||||
{/* 链接图标 */}
|
||||
<div className='w-10 h-10 rounded-full flex items-center justify-center'>
|
||||
<Link size={20} />
|
||||
</div>
|
||||
{/* 站点Logo */}
|
||||
<div className='w-12 h-12 rounded-full overflow-hidden flex items-center justify-center'>
|
||||
<img
|
||||
src={getLogo()}
|
||||
alt='Site Logo'
|
||||
className='w-full h-full object-cover'
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='w-full h-full rounded-full flex items-center justify-center'
|
||||
style={{
|
||||
backgroundColor: stringToColor(
|
||||
window.location.hostname || 'S',
|
||||
),
|
||||
display: 'none',
|
||||
}}
|
||||
>
|
||||
<Text className='font-bold text-lg'>
|
||||
{window.location.hostname.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Title heading={4}>
|
||||
{t('授权')} {info?.client?.name || info?.client?.id}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Divider margin='0' />
|
||||
|
||||
{/* 用户信息 */}
|
||||
<div className='px-3 sm:px-5 py-3'>
|
||||
<div className='flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<Text className='block text-sm sm:text-base'>
|
||||
<Text strong>
|
||||
{info?.client?.name || info?.client?.id}
|
||||
</Text>{' '}
|
||||
{t('由')}{' '}
|
||||
<Text strong>
|
||||
{info?.client?.domain || t('未知域')}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text type='tertiary' size='small' className='block mt-1'>
|
||||
{t('想要访问你的')}{' '}
|
||||
<Text strong>{info?.user?.name || ''}</Text> {t('账户')}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
size='small'
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
className='w-full sm:w-auto flex-shrink-0'
|
||||
onClick={() => {
|
||||
const u = new URL(window.location.origin + '/login');
|
||||
u.searchParams.set(
|
||||
'next',
|
||||
'/oauth/consent' + window.location.search,
|
||||
);
|
||||
window.location.href = u.toString();
|
||||
}}
|
||||
>
|
||||
{t('切换账户')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider margin='0' />
|
||||
|
||||
{/* 权限列表 */}
|
||||
<div className='px-3 sm:px-5 py-3'>
|
||||
<div className='space-y-2'>
|
||||
{info?.scope_info?.length ? (
|
||||
info.scope_info.map((scope) => (
|
||||
<ScopeItem
|
||||
key={scope.Name}
|
||||
name={scope.Name}
|
||||
description={scope.Description}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className='space-y-1'>
|
||||
{info?.scope_list?.map((name) => (
|
||||
<ScopeItem key={name} name={name} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Meta信息Card */}
|
||||
<Card bordered={false}>
|
||||
<div className='text-center'>
|
||||
<div className='flex flex-wrap justify-center gap-x-2 gap-y-1 items-center'>
|
||||
<Text size='small'>
|
||||
{t('客户端ID')}: {info?.client?.id?.slice(-8) || 'N/A'}
|
||||
</Text>
|
||||
<Dot size={16} />
|
||||
<Text size='small'>
|
||||
{t('类型')}:{' '}
|
||||
{info?.client?.type === 'public'
|
||||
? t('公开应用')
|
||||
: t('机密应用')}
|
||||
</Text>
|
||||
{info?.response_type && (
|
||||
<>
|
||||
<Dot size={16} />
|
||||
<Text size='small'>
|
||||
{t('授权类型')}:{' '}
|
||||
{info.response_type === 'code'
|
||||
? t('授权码')
|
||||
: info.response_type}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{info?.require_pkce && (
|
||||
<>
|
||||
<Dot size={16} />
|
||||
<Text size='small'>PKCE: {t('已启用')}</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{info?.state && (
|
||||
<div className='mt-2'>
|
||||
<Text type='tertiary' size='small' className='font-mono'>
|
||||
State: {info.state}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -130,19 +130,20 @@ export default function GeneralSettings(props) {
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
{inputs.QuotaPerUnit !== '500000' && inputs.QuotaPerUnit !== 500000 && (
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'QuotaPerUnit'}
|
||||
label={t('单位美元额度')}
|
||||
initValue={''}
|
||||
placeholder={t('一单位货币能兑换的额度')}
|
||||
onChange={handleFieldChange('QuotaPerUnit')}
|
||||
showClear
|
||||
onClick={() => setShowQuotaWarning(true)}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
{inputs.QuotaPerUnit !== '500000' &&
|
||||
inputs.QuotaPerUnit !== 500000 && (
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'QuotaPerUnit'}
|
||||
label={t('单位美元额度')}
|
||||
initValue={''}
|
||||
placeholder={t('一单位货币能兑换的额度')}
|
||||
onChange={handleFieldChange('QuotaPerUnit')}
|
||||
showClear
|
||||
onClick={() => setShowQuotaWarning(true)}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'USDExchangeRate'}
|
||||
|
||||
@@ -128,7 +128,8 @@ export default function SettingsMonitoring(props) {
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'monitor_setting.auto_test_channel_minutes': parseInt(value),
|
||||
'monitor_setting.auto_test_channel_minutes':
|
||||
parseInt(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -118,14 +118,20 @@ export default function SettingsPaymentGateway(props) {
|
||||
}
|
||||
}
|
||||
|
||||
if (originInputs['AmountOptions'] !== inputs.AmountOptions && inputs.AmountOptions.trim() !== '') {
|
||||
if (
|
||||
originInputs['AmountOptions'] !== inputs.AmountOptions &&
|
||||
inputs.AmountOptions.trim() !== ''
|
||||
) {
|
||||
if (!verifyJSON(inputs.AmountOptions)) {
|
||||
showError(t('自定义充值数量选项不是合法的 JSON 数组'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (originInputs['AmountDiscount'] !== inputs.AmountDiscount && inputs.AmountDiscount.trim() !== '') {
|
||||
if (
|
||||
originInputs['AmountDiscount'] !== inputs.AmountDiscount &&
|
||||
inputs.AmountDiscount.trim() !== ''
|
||||
) {
|
||||
if (!verifyJSON(inputs.AmountDiscount)) {
|
||||
showError(t('充值金额折扣配置不是合法的 JSON 对象'));
|
||||
return;
|
||||
@@ -163,10 +169,16 @@ export default function SettingsPaymentGateway(props) {
|
||||
options.push({ key: 'PayMethods', value: inputs.PayMethods });
|
||||
}
|
||||
if (originInputs['AmountOptions'] !== inputs.AmountOptions) {
|
||||
options.push({ key: 'payment_setting.amount_options', value: inputs.AmountOptions });
|
||||
options.push({
|
||||
key: 'payment_setting.amount_options',
|
||||
value: inputs.AmountOptions,
|
||||
});
|
||||
}
|
||||
if (originInputs['AmountDiscount'] !== inputs.AmountDiscount) {
|
||||
options.push({ key: 'payment_setting.amount_discount', value: inputs.AmountDiscount });
|
||||
options.push({
|
||||
key: 'payment_setting.amount_discount',
|
||||
value: inputs.AmountDiscount,
|
||||
});
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
@@ -273,7 +285,7 @@ export default function SettingsPaymentGateway(props) {
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
autosize
|
||||
/>
|
||||
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
@@ -282,13 +294,17 @@ export default function SettingsPaymentGateway(props) {
|
||||
<Form.TextArea
|
||||
field='AmountOptions'
|
||||
label={t('自定义充值数量选项')}
|
||||
placeholder={t('为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]')}
|
||||
placeholder={t(
|
||||
'为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]',
|
||||
)}
|
||||
autosize
|
||||
extraText={t('设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]')}
|
||||
extraText={t(
|
||||
'设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
@@ -297,13 +313,17 @@ export default function SettingsPaymentGateway(props) {
|
||||
<Form.TextArea
|
||||
field='AmountDiscount'
|
||||
label={t('充值金额折扣配置')}
|
||||
placeholder={t('为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
|
||||
placeholder={t(
|
||||
'为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
|
||||
)}
|
||||
autosize
|
||||
extraText={t('设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
|
||||
extraText={t(
|
||||
'设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
<Button onClick={submitPayAddress}>{t('更新支付设置')}</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
|
||||
@@ -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: <ModelSetting />,
|
||||
itemKey: 'models',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Shield size={18} />
|
||||
{t('OAuth2 & SSO')}
|
||||
</span>
|
||||
),
|
||||
content: <OAuth2Setting />,
|
||||
itemKey: 'oauth2',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
|
||||
Reference in New Issue
Block a user