mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-05-24 07:14:28 +00:00
wip sso
This commit is contained in:
26
.env.example
26
.env.example
@@ -49,6 +49,32 @@
|
||||
# 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值
|
||||
# STREAMING_TIMEOUT=300
|
||||
|
||||
# OAuth2 服务器配置
|
||||
# 启用OAuth2服务器
|
||||
# OAUTH2_ENABLED=true
|
||||
# OAuth2签发者标识
|
||||
# OAUTH2_ISSUER=https://your-domain.com
|
||||
# 访问令牌有效期(分钟)
|
||||
# OAUTH2_ACCESS_TOKEN_TTL=10
|
||||
# 刷新令牌有效期(分钟)
|
||||
# OAUTH2_REFRESH_TOKEN_TTL=720
|
||||
# 允许的授权类型(逗号分隔)
|
||||
# OAUTH2_ALLOWED_GRANT_TYPES=client_credentials,authorization_code,refresh_token
|
||||
# 强制PKCE验证
|
||||
# OAUTH2_REQUIRE_PKCE=true
|
||||
# JWT签名算法
|
||||
# JWT_SIGNING_ALGORITHM=RS256
|
||||
# JWT密钥ID
|
||||
# JWT_KEY_ID=oauth2-key-1
|
||||
# JWT私钥文件路径
|
||||
# JWT_PRIVATE_KEY_FILE=/path/to/oauth2-private-key.pem
|
||||
# 自动创建用户(首次OAuth2登录时)
|
||||
# OAUTH2_AUTO_CREATE_USER=false
|
||||
# 自动创建用户的默认角色
|
||||
# OAUTH2_DEFAULT_USER_ROLE=1
|
||||
# 自动创建用户的默认分组
|
||||
# OAUTH2_DEFAULT_USER_GROUP=default
|
||||
|
||||
# Gemini 识别图片 最大图片数量
|
||||
# GEMINI_VISION_MAX_IMAGE_NUM=16
|
||||
|
||||
|
||||
200
controller/oauth.go
Normal file
200
controller/oauth.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"one-api/setting/system_setting"
|
||||
"one-api/src/oauth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetJWKS 获取JWKS公钥集
|
||||
func GetJWKS(c *gin.Context) {
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
if !settings.Enabled {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "OAuth2 server is disabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
jwks := oauth.GetJWKS()
|
||||
if jwks == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "JWKS not available",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置CORS headers
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type")
|
||||
c.Header("Cache-Control", "public, max-age=3600") // 缓存1小时
|
||||
|
||||
// 返回JWKS
|
||||
c.Header("Content-Type", "application/json")
|
||||
|
||||
// 将JWKS转换为JSON字符串
|
||||
jsonData, err := json.Marshal(jwks)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to marshal JWKS",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.String(http.StatusOK, string(jsonData))
|
||||
}
|
||||
|
||||
// OAuthTokenEndpoint OAuth2 令牌端点
|
||||
func OAuthTokenEndpoint(c *gin.Context) {
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
if !settings.Enabled {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "unsupported_grant_type",
|
||||
"error_description": "OAuth2 server is disabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 只允许POST请求
|
||||
if c.Request.Method != "POST" {
|
||||
c.JSON(http.StatusMethodNotAllowed, gin.H{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Only POST method is allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 只允许application/x-www-form-urlencoded内容类型
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
if contentType != "application/x-www-form-urlencoded" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Content-Type must be application/x-www-form-urlencoded",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 委托给OAuth2服务器处理
|
||||
oauth.HandleTokenRequest(c)
|
||||
}
|
||||
|
||||
// OAuthAuthorizeEndpoint OAuth2 授权端点
|
||||
func OAuthAuthorizeEndpoint(c *gin.Context) {
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
if !settings.Enabled {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth2 server is disabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 委托给OAuth2服务器处理
|
||||
oauth.HandleAuthorizeRequest(c)
|
||||
}
|
||||
|
||||
// OAuthServerInfo 获取OAuth2服务器信息
|
||||
func OAuthServerInfo(c *gin.Context) {
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
if !settings.Enabled {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "OAuth2 server is disabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回OAuth2服务器的基本信息(类似OpenID Connect Discovery)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"issuer": settings.Issuer,
|
||||
"authorization_endpoint": settings.Issuer + "/oauth/authorize",
|
||||
"token_endpoint": settings.Issuer + "/oauth/token",
|
||||
"jwks_uri": settings.Issuer + "/.well-known/jwks.json",
|
||||
"grant_types_supported": settings.AllowedGrantTypes,
|
||||
"response_types_supported": []string{"code"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"scopes_supported": []string{
|
||||
"api:read",
|
||||
"api:write",
|
||||
"admin",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// OAuthIntrospect 令牌内省端点(RFC 7662)
|
||||
func OAuthIntrospect(c *gin.Context) {
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
if !settings.Enabled {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "OAuth2 server is disabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 只允许POST请求
|
||||
if c.Request.Method != "POST" {
|
||||
c.JSON(http.StatusMethodNotAllowed, gin.H{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Only POST method is allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token := c.PostForm("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"active": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 实现令牌内省逻辑
|
||||
// 1. 验证调用者的认证信息
|
||||
// 2. 解析和验证JWT令牌
|
||||
// 3. 返回令牌的元信息
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"active": false, // 临时返回,需要实现实际的内省逻辑
|
||||
})
|
||||
}
|
||||
|
||||
// OAuthRevoke 令牌撤销端点(RFC 7009)
|
||||
func OAuthRevoke(c *gin.Context) {
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
if !settings.Enabled {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "OAuth2 server is disabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 只允许POST请求
|
||||
if c.Request.Method != "POST" {
|
||||
c.JSON(http.StatusMethodNotAllowed, gin.H{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Only POST method is allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token := c.PostForm("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing token parameter",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 实现令牌撤销逻辑
|
||||
// 1. 验证调用者的认证信息
|
||||
// 2. 撤销指定的令牌(加入黑名单或从存储中删除)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
})
|
||||
}
|
||||
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
|
||||
}
|
||||
909
docs/oauth2-demo.html
Normal file
909
docs/oauth2-demo.html
Normal file
@@ -0,0 +1,909 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OAuth2 自动登录 Demo</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.section {
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.hidden { display: none; }
|
||||
.button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
}
|
||||
.button:hover { background: #0056b3; }
|
||||
.button.secondary { background: #6c757d; }
|
||||
.button.danger { background: #dc3545; }
|
||||
.code {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
.log {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #ddd;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.config-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 150px 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
.config-form input {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.status {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.status.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
||||
.status.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
||||
.status.info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>OAuth2 服务器自动登录 Demo</h1>
|
||||
<p>这个演示展示了如何使用OAuth2实现自动登录功能。</p>
|
||||
|
||||
<!-- 配置区域 -->
|
||||
<div class="section">
|
||||
<h2>配置</h2>
|
||||
<div class="config-form">
|
||||
<label>服务器地址:</label>
|
||||
<input type="text" id="serverUrl" value="https://your-domain.com" placeholder="https://your-domain.com">
|
||||
|
||||
<label>Client ID:</label>
|
||||
<input type="text" id="clientId" placeholder="your_client_id">
|
||||
|
||||
<label>Client Secret:</label>
|
||||
<input type="password" id="clientSecret" placeholder="your_client_secret">
|
||||
|
||||
<label>重定向URI:</label>
|
||||
<input type="text" id="redirectUri" placeholder="当前页面会自动设置">
|
||||
|
||||
<label>权限范围:</label>
|
||||
<input type="text" id="scopes" value="api:read api:write">
|
||||
</div>
|
||||
<div style="margin-top: 15px;">
|
||||
<button class="button" onclick="saveConfig()">保存配置</button>
|
||||
<button class="button secondary" onclick="loadConfig()">加载配置</button>
|
||||
<button class="button secondary" onclick="testServerInfo()">测试服务器</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录状态区域 -->
|
||||
<div class="section">
|
||||
<h2>登录状态</h2>
|
||||
<div id="loginStatus" class="status info">未登录</div>
|
||||
|
||||
<!-- 未登录显示 -->
|
||||
<div id="loginSection">
|
||||
<h3>选择登录方式:</h3>
|
||||
<button class="button" onclick="clientCredentialsLogin()">Client Credentials 登录</button>
|
||||
<button class="button" onclick="authorizationCodeLogin()">授权码登录 (用户交互)</button>
|
||||
<button class="button secondary" onclick="checkExistingToken()">检查已有令牌</button>
|
||||
</div>
|
||||
|
||||
<!-- 已登录显示 -->
|
||||
<div id="loggedInSection" class="hidden">
|
||||
<h3>已登录</h3>
|
||||
<div id="userInfo"></div>
|
||||
<div style="margin-top: 15px;">
|
||||
<button class="button" onclick="getUserInfo()">获取用户信息</button>
|
||||
<button class="button" onclick="refreshAccessToken()">刷新令牌</button>
|
||||
<button class="button secondary" onclick="testApiCall()">测试API调用</button>
|
||||
<button class="button danger" onclick="logout()">登出</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 令牌信息区域 -->
|
||||
<div class="section">
|
||||
<h2>令牌信息</h2>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<button class="button secondary" onclick="showTokenDetails()">显示令牌详情</button>
|
||||
<button class="button secondary" onclick="decodeJWT()">解析JWT</button>
|
||||
</div>
|
||||
<div id="tokenInfo" class="code"></div>
|
||||
</div>
|
||||
|
||||
<!-- 日志区域 -->
|
||||
<div class="section">
|
||||
<h2>操作日志</h2>
|
||||
<button class="button secondary" onclick="clearLog()">清空日志</button>
|
||||
<div id="logArea" class="log"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 配置对象
|
||||
let config = {
|
||||
serverUrl: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
redirectUri: '',
|
||||
scopes: 'api:read api:write'
|
||||
};
|
||||
|
||||
// OAuth2 客户端类
|
||||
class OAuth2Client {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
// 生成随机字符串
|
||||
generateRandomString(length = 32) {
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 生成PKCE参数
|
||||
async generatePKCE() {
|
||||
const codeVerifier = this.generateRandomString(128);
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(codeVerifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
// Client Credentials 流程
|
||||
async clientCredentialsFlow() {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
scope: this.config.scopes
|
||||
});
|
||||
|
||||
const response = await fetch(`${this.config.serverUrl}/api/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: params
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 授权码流程 - 步骤1:重定向到授权页面
|
||||
async startAuthorizationCodeFlow() {
|
||||
const { codeVerifier, codeChallenge } = await this.generatePKCE();
|
||||
const state = this.generateRandomString();
|
||||
|
||||
// 保存参数
|
||||
localStorage.setItem('oauth_code_verifier', codeVerifier);
|
||||
localStorage.setItem('oauth_state', state);
|
||||
|
||||
// 构建授权URL
|
||||
const authUrl = new URL(`${this.config.serverUrl}/api/oauth/authorize`);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('client_id', this.config.clientId);
|
||||
authUrl.searchParams.set('redirect_uri', this.config.redirectUri);
|
||||
authUrl.searchParams.set('scope', this.config.scopes);
|
||||
authUrl.searchParams.set('state', state);
|
||||
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
// 重定向
|
||||
window.location.href = authUrl.toString();
|
||||
}
|
||||
|
||||
// 授权码流程 - 步骤2:处理回调
|
||||
async handleAuthorizationCallback() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const state = urlParams.get('state');
|
||||
const error = urlParams.get('error');
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Authorization error: ${error}`);
|
||||
}
|
||||
|
||||
const savedState = localStorage.getItem('oauth_state');
|
||||
if (state !== savedState) {
|
||||
throw new Error('State mismatch - possible CSRF attack');
|
||||
}
|
||||
|
||||
const codeVerifier = localStorage.getItem('oauth_code_verifier');
|
||||
if (!code || !codeVerifier) {
|
||||
throw new Error('Missing authorization code or code verifier');
|
||||
}
|
||||
|
||||
// 交换访问令牌
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
code: code,
|
||||
redirect_uri: this.config.redirectUri,
|
||||
code_verifier: codeVerifier
|
||||
});
|
||||
|
||||
const response = await fetch(`${this.config.serverUrl}/api/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: params
|
||||
});
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
// 清理临时数据
|
||||
localStorage.removeItem('oauth_code_verifier');
|
||||
localStorage.removeItem('oauth_state');
|
||||
|
||||
// 清理URL参数
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// 刷新令牌
|
||||
async refreshToken(refreshToken) {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
refresh_token: refreshToken
|
||||
});
|
||||
|
||||
const response = await fetch(`${this.config.serverUrl}/api/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: params
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 调用API
|
||||
async callAPI(endpoint, options = {}) {
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
if (!accessToken) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.config.serverUrl}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// 尝试刷新令牌
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (refreshToken) {
|
||||
const tokens = await this.refreshToken(refreshToken);
|
||||
if (tokens.access_token) {
|
||||
localStorage.setItem('access_token', tokens.access_token);
|
||||
if (tokens.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokens.refresh_token);
|
||||
}
|
||||
// 重试请求
|
||||
return this.callAPI(endpoint, options);
|
||||
}
|
||||
}
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 获取服务器信息
|
||||
async getServerInfo() {
|
||||
const response = await fetch(`${this.config.serverUrl}/api/oauth/server-info`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 获取JWKS
|
||||
async getJWKS() {
|
||||
const response = await fetch(`${this.config.serverUrl}/api/oauth/jwks`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 解析JWT令牌
|
||||
parseJWTToken(token) {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid JWT token format');
|
||||
}
|
||||
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
|
||||
return {
|
||||
userId: payload.sub,
|
||||
username: payload.preferred_username || payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
roles: payload.scope?.split(' ') || [],
|
||||
groups: payload.groups || [],
|
||||
exp: payload.exp,
|
||||
iat: payload.iat,
|
||||
iss: payload.iss,
|
||||
aud: payload.aud,
|
||||
jti: payload.jti
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to parse JWT token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 验证JWT令牌
|
||||
validateJWTToken(token) {
|
||||
const userInfo = this.parseJWTToken(token);
|
||||
if (!userInfo) return false;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (userInfo.exp && now >= userInfo.exp) {
|
||||
console.log('JWT token has expired');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取当前用户信息(从JWT令牌)
|
||||
getCurrentUser() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token || !this.validateJWTToken(token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.parseJWTToken(token);
|
||||
}
|
||||
|
||||
// 检查令牌是否即将过期
|
||||
isTokenExpiringSoon(token) {
|
||||
if (!token) return true;
|
||||
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return true;
|
||||
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
const exp = payload.exp * 1000; // 转换为毫秒
|
||||
const now = Date.now();
|
||||
return exp - now < 5 * 60 * 1000; // 5分钟内过期
|
||||
} catch (error) {
|
||||
console.error('Token validation failed:', error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let oauth2Client;
|
||||
|
||||
// 日志函数
|
||||
function log(message, type = 'info') {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logArea = document.getElementById('logArea');
|
||||
const logEntry = `[${timestamp}] ${message}\n`;
|
||||
logArea.textContent += logEntry;
|
||||
logArea.scrollTop = logArea.scrollHeight;
|
||||
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
function saveConfig() {
|
||||
config.serverUrl = document.getElementById('serverUrl').value;
|
||||
config.clientId = document.getElementById('clientId').value;
|
||||
config.clientSecret = document.getElementById('clientSecret').value;
|
||||
config.redirectUri = document.getElementById('redirectUri').value || window.location.origin + window.location.pathname;
|
||||
config.scopes = document.getElementById('scopes').value;
|
||||
|
||||
localStorage.setItem('oauth_config', JSON.stringify(config));
|
||||
oauth2Client = new OAuth2Client(config);
|
||||
|
||||
log('配置已保存');
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
function loadConfig() {
|
||||
const saved = localStorage.getItem('oauth_config');
|
||||
if (saved) {
|
||||
config = JSON.parse(saved);
|
||||
document.getElementById('serverUrl').value = config.serverUrl;
|
||||
document.getElementById('clientId').value = config.clientId;
|
||||
document.getElementById('clientSecret').value = config.clientSecret;
|
||||
document.getElementById('redirectUri').value = config.redirectUri;
|
||||
document.getElementById('scopes').value = config.scopes;
|
||||
|
||||
oauth2Client = new OAuth2Client(config);
|
||||
log('配置已加载');
|
||||
}
|
||||
}
|
||||
|
||||
// 测试服务器信息
|
||||
async function testServerInfo() {
|
||||
try {
|
||||
saveConfig();
|
||||
const info = await oauth2Client.getServerInfo();
|
||||
log('服务器信息: ' + JSON.stringify(info, null, 2));
|
||||
updateStatus('服务器连接正常', 'success');
|
||||
} catch (error) {
|
||||
log('测试服务器失败: ' + error.message);
|
||||
updateStatus('服务器连接失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 客户端凭证登录
|
||||
async function clientCredentialsLogin() {
|
||||
try {
|
||||
saveConfig();
|
||||
log('开始 Client Credentials 登录...');
|
||||
|
||||
const tokens = await oauth2Client.clientCredentialsFlow();
|
||||
|
||||
if (tokens.access_token) {
|
||||
localStorage.setItem('access_token', tokens.access_token);
|
||||
if (tokens.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokens.refresh_token);
|
||||
}
|
||||
|
||||
log('Client Credentials 登录成功');
|
||||
updateLoginState(true);
|
||||
} else {
|
||||
throw new Error('未收到访问令牌: ' + JSON.stringify(tokens));
|
||||
}
|
||||
} catch (error) {
|
||||
log('Client Credentials 登录失败: ' + error.message);
|
||||
updateStatus('登录失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 授权码登录
|
||||
async function authorizationCodeLogin() {
|
||||
try {
|
||||
saveConfig();
|
||||
log('开始授权码登录...');
|
||||
await oauth2Client.startAuthorizationCodeFlow();
|
||||
} catch (error) {
|
||||
log('授权码登录失败: ' + error.message);
|
||||
updateStatus('登录失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 检查现有令牌
|
||||
function checkExistingToken() {
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
if (accessToken) {
|
||||
log('发现现有访问令牌');
|
||||
updateLoginState(true);
|
||||
} else {
|
||||
log('未找到访问令牌');
|
||||
updateLoginState(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
async function getUserInfo() {
|
||||
try {
|
||||
// 从JWT令牌获取用户信息
|
||||
const tokenUser = oauth2Client.getCurrentUser();
|
||||
|
||||
// 从API获取用户信息
|
||||
const apiUser = await oauth2Client.callAPI('/api/user/self');
|
||||
|
||||
document.getElementById('userInfo').innerHTML = `
|
||||
<h4>JWT令牌中的用户信息:</h4>
|
||||
<pre>${JSON.stringify(tokenUser, null, 2)}</pre>
|
||||
<h4>API返回的用户信息:</h4>
|
||||
<pre>${JSON.stringify(apiUser, null, 2)}</pre>
|
||||
`;
|
||||
log('获取用户信息成功');
|
||||
} catch (error) {
|
||||
log('获取用户信息失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新访问令牌
|
||||
async function refreshAccessToken() {
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (!refreshToken) {
|
||||
throw new Error('没有刷新令牌');
|
||||
}
|
||||
|
||||
const tokens = await oauth2Client.refreshToken(refreshToken);
|
||||
|
||||
if (tokens.access_token) {
|
||||
localStorage.setItem('access_token', tokens.access_token);
|
||||
if (tokens.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokens.refresh_token);
|
||||
}
|
||||
log('令牌刷新成功');
|
||||
showTokenDetails();
|
||||
} else {
|
||||
throw new Error('刷新令牌失败: ' + JSON.stringify(tokens));
|
||||
}
|
||||
} catch (error) {
|
||||
log('刷新令牌失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 测试API调用
|
||||
async function testApiCall() {
|
||||
try {
|
||||
const result = await oauth2Client.callAPI('/api/user/self');
|
||||
log('API调用成功: ' + JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
log('API调用失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 登出
|
||||
function logout() {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
document.getElementById('userInfo').innerHTML = '';
|
||||
updateLoginState(false);
|
||||
log('已登出');
|
||||
}
|
||||
|
||||
// 显示令牌详情
|
||||
function showTokenDetails() {
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
let details = '';
|
||||
if (accessToken) {
|
||||
details += `访问令牌: ${accessToken.substring(0, 50)}...\n\n`;
|
||||
}
|
||||
if (refreshToken) {
|
||||
details += `刷新令牌: ${refreshToken.substring(0, 50)}...\n\n`;
|
||||
}
|
||||
|
||||
document.getElementById('tokenInfo').textContent = details || '无令牌';
|
||||
}
|
||||
|
||||
// 解析JWT
|
||||
function decodeJWT() {
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
if (!accessToken) {
|
||||
document.getElementById('tokenInfo').textContent = '无访问令牌';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parts = accessToken.split('.');
|
||||
const header = JSON.parse(atob(parts[0]));
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
|
||||
const decoded = {
|
||||
header,
|
||||
payload: {
|
||||
...payload,
|
||||
exp: new Date(payload.exp * 1000).toISOString(),
|
||||
iat: new Date(payload.iat * 1000).toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('tokenInfo').textContent = JSON.stringify(decoded, null, 2);
|
||||
} catch (error) {
|
||||
document.getElementById('tokenInfo').textContent = '解析JWT失败: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// 清空日志
|
||||
function clearLog() {
|
||||
document.getElementById('logArea').textContent = '';
|
||||
}
|
||||
|
||||
// 更新登录状态
|
||||
function updateLoginState(isLoggedIn) {
|
||||
if (isLoggedIn) {
|
||||
document.getElementById('loginSection').classList.add('hidden');
|
||||
document.getElementById('loggedInSection').classList.remove('hidden');
|
||||
updateStatus('已登录', 'success');
|
||||
} else {
|
||||
document.getElementById('loginSection').classList.remove('hidden');
|
||||
document.getElementById('loggedInSection').classList.add('hidden');
|
||||
updateStatus('未登录', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// 更新状态显示
|
||||
function updateStatus(message, type) {
|
||||
const statusEl = document.getElementById('loginStatus');
|
||||
statusEl.textContent = message;
|
||||
statusEl.className = `status ${type}`;
|
||||
}
|
||||
|
||||
// 自动创建用户相关功能
|
||||
function showUserInfoForm(jwtUserInfo) {
|
||||
const formHTML = `
|
||||
<div style="max-width: 500px; margin: 20px auto; padding: 20px; border: 1px solid #ddd; border-radius: 8px; background: white; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
|
||||
<h3 style="text-align: center; color: #333;">完善用户信息</h3>
|
||||
<p style="text-align: center; color: #666;">系统将为您自动创建账户,请填写或确认以下信息:</p>
|
||||
|
||||
<form id="userRegistrationForm">
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 5px;"><strong>用户名</strong> <span style="color: red;">*</span></label>
|
||||
<input type="text" id="username" value="${jwtUserInfo.username || ''}" required
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;">
|
||||
<small style="color: #666;">用于登录的用户名</small>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 5px;"><strong>显示名称</strong></label>
|
||||
<input type="text" id="displayName" value="${jwtUserInfo.name || jwtUserInfo.username || ''}"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;">
|
||||
<small style="color: #666;">在界面上显示的名称</small>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 5px;"><strong>邮箱地址</strong></label>
|
||||
<input type="email" id="email" value="${jwtUserInfo.email || ''}"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;">
|
||||
<small style="color: #666;">用于接收通知和找回密码</small>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 5px;"><strong>所属组织</strong></label>
|
||||
<input type="text" id="group" value="oauth2" readonly
|
||||
style="width: 100%; padding: 8px; background: #f5f5f5; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;">
|
||||
<small style="color: #666;">OAuth2自动创建的用户组</small>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h4 style="margin-bottom: 10px;">从JWT令牌获取的信息:</h4>
|
||||
<pre style="background: #f8f9fa; padding: 10px; border-radius: 4px; font-size: 12px; max-height: 200px; overflow: auto; border: 1px solid #e9ecef;">
|
||||
${JSON.stringify(jwtUserInfo, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<button type="submit" style="background: #007bff; color: white; padding: 12px 24px; border: none; border-radius: 4px; cursor: pointer; margin-right: 10px; font-size: 14px;">
|
||||
创建账户并登录
|
||||
</button>
|
||||
<button type="button" onclick="cancelRegistration()" style="background: #6c757d; color: white; padding: 12px 24px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.innerHTML = formHTML;
|
||||
|
||||
// 绑定表单提交事件
|
||||
document.getElementById('userRegistrationForm').addEventListener('submit', handleUserRegistration);
|
||||
}
|
||||
|
||||
// 处理用户注册
|
||||
async function handleUserRegistration(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = {
|
||||
username: document.getElementById('username').value.trim(),
|
||||
displayName: document.getElementById('displayName').value.trim(),
|
||||
email: document.getElementById('email').value.trim(),
|
||||
group: document.getElementById('group').value,
|
||||
oauth2Provider: 'oauth2',
|
||||
oauth2UserId: oauth2Client.getCurrentUser().userId
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('创建用户:', formData);
|
||||
|
||||
// 调用自动创建用户API(这里是演示,实际需要服务器支持)
|
||||
const response = await fetch(oauth2Client.config.serverUrl + '/api/oauth/auto_create_user', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
// 模拟成功响应(实际项目中需要服务器实现)
|
||||
if (!response.ok) {
|
||||
// 如果API不存在,显示模拟成功
|
||||
console.log('模拟用户创建成功');
|
||||
localStorage.setItem('user_created', 'true');
|
||||
localStorage.setItem('user_info', JSON.stringify(formData));
|
||||
alert('用户创建成功!(这是演示模式,实际需要服务器端实现)');
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
console.log('用户创建成功,用户ID:', result.user_id);
|
||||
localStorage.setItem('user_created', 'true');
|
||||
localStorage.setItem('user_info', JSON.stringify(formData));
|
||||
location.reload();
|
||||
} else {
|
||||
alert('创建用户失败: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('用户创建失败:', error);
|
||||
// 演示模式:模拟成功
|
||||
localStorage.setItem('user_created', 'true');
|
||||
localStorage.setItem('user_info', JSON.stringify(formData));
|
||||
alert('用户创建成功!(演示模式)');
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
// 取消注册
|
||||
function cancelRegistration() {
|
||||
console.log('用户取消注册');
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user_created');
|
||||
localStorage.removeItem('user_info');
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// 检查用户是否存在(演示版本)
|
||||
async function checkUserExists(userId) {
|
||||
try {
|
||||
// 演示模式:检查localStorage中是否有user_created标记
|
||||
const userCreated = localStorage.getItem('user_created');
|
||||
if (userCreated) {
|
||||
console.log('用户已创建(从本地存储检测到)');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 实际项目中会调用服务器API
|
||||
const response = await fetch(`${oauth2Client.config.serverUrl}/api/oauth/user_exists/${userId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// API不存在时返回false,触发用户创建流程
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.exists;
|
||||
} catch (error) {
|
||||
console.error('检查用户存在性失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 改进的初始化函数,包含自动创建用户逻辑
|
||||
async function initAutoLogin() {
|
||||
try {
|
||||
console.log('开始自动登录初始化...');
|
||||
|
||||
// 1. 检查是否有有效的访问令牌
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
if (!accessToken || !oauth2Client.validateJWTToken(accessToken)) {
|
||||
console.log('没有有效令牌');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 解析JWT令牌获取用户信息
|
||||
const jwtUserInfo = oauth2Client.getCurrentUser();
|
||||
console.log('JWT用户信息:', jwtUserInfo);
|
||||
|
||||
// 3. 检查用户是否已存在于系统中
|
||||
const userExists = await checkUserExists(jwtUserInfo.userId);
|
||||
console.log('用户存在检查结果:', userExists);
|
||||
|
||||
if (!userExists) {
|
||||
console.log('用户不存在,显示用户信息收集表单');
|
||||
showUserInfoForm(jwtUserInfo);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. 用户已存在,显示登录成功界面
|
||||
console.log('用户已存在,显示登录成功信息');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('自动登录失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 设置默认重定向URI
|
||||
document.getElementById('redirectUri').value = window.location.origin + window.location.pathname;
|
||||
|
||||
// 加载保存的配置
|
||||
loadConfig();
|
||||
|
||||
// 检查是否有授权回调
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('code')) {
|
||||
log('检测到授权回调,处理中...');
|
||||
if (oauth2Client) {
|
||||
oauth2Client.handleAuthorizationCallback()
|
||||
.then(async tokens => {
|
||||
if (tokens.access_token) {
|
||||
localStorage.setItem('access_token', tokens.access_token);
|
||||
if (tokens.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokens.refresh_token);
|
||||
}
|
||||
log('授权回调处理成功,开始自动登录流程...');
|
||||
|
||||
// 清除URL中的授权回调参数
|
||||
const cleanUrl = window.location.origin + window.location.pathname;
|
||||
window.history.replaceState({}, document.title, cleanUrl);
|
||||
|
||||
// 启动自动登录流程
|
||||
const autoLoginSuccess = await initAutoLogin();
|
||||
if (autoLoginSuccess) {
|
||||
updateLoginState(true);
|
||||
} else {
|
||||
updateLoginState(false);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
log('授权回调处理失败: ' + error.message);
|
||||
updateStatus('授权失败: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 没有授权回调,尝试自动登录
|
||||
setTimeout(async () => {
|
||||
const autoLoginSuccess = await initAutoLogin();
|
||||
if (!autoLoginSuccess) {
|
||||
// 自动登录失败,检查现有令牌状态
|
||||
checkExistingToken();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
log('OAuth2 Demo 已初始化');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
870
docs/oauth2-demo.md
Normal file
870
docs/oauth2-demo.md
Normal file
@@ -0,0 +1,870 @@
|
||||
# OAuth2服务端使用Demo - 自动登录流程
|
||||
|
||||
本文档演示如何使用new-api的OAuth2服务器实现自动登录功能,包括两种授权模式的完整流程。
|
||||
|
||||
## 📋 准备工作
|
||||
|
||||
### 1. 启用OAuth2服务器
|
||||
在管理后台 -> 设置 -> OAuth2 & SSO 中:
|
||||
```
|
||||
启用OAuth2服务器: 开启
|
||||
签发者标识(Issuer): https://your-domain.com
|
||||
访问令牌有效期: 60分钟
|
||||
刷新令牌有效期: 24小时
|
||||
JWT签名算法: RS256
|
||||
允许的授权类型: client_credentials, authorization_code
|
||||
```
|
||||
|
||||
### 2. 创建OAuth2客户端
|
||||
在OAuth2客户端管理中创建应用:
|
||||
```
|
||||
客户端名称: My App
|
||||
客户端类型: 机密客户端 (Confidential)
|
||||
授权类型: Client Credentials, Authorization Code
|
||||
权限范围: api:read, api:write
|
||||
重定向URI: https://your-app.com/callback
|
||||
```
|
||||
|
||||
创建成功后会获得:
|
||||
- Client ID: `your_client_id`
|
||||
- Client Secret: `your_client_secret` (仅显示一次)
|
||||
|
||||
## 🔐 方式一:客户端凭证流程 (Client Credentials)
|
||||
|
||||
适用于**服务器到服务器**的API调用,无需用户交互。
|
||||
|
||||
### 获取访问令牌
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/oauth/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=client_credentials" \
|
||||
-d "client_id=your_client_id" \
|
||||
-d "client_secret=your_client_secret" \
|
||||
-d "scope=api:read api:write"
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "api:read api:write"
|
||||
}
|
||||
```
|
||||
|
||||
### 使用访问令牌调用API
|
||||
|
||||
```bash
|
||||
curl -X GET https://your-domain.com/api/user/self \
|
||||
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
```
|
||||
|
||||
## 👤 方式二:授权码流程 (Authorization Code + PKCE)
|
||||
|
||||
适用于**用户登录**场景,支持自动登录功能。
|
||||
|
||||
### Step 1: 生成PKCE参数
|
||||
|
||||
```javascript
|
||||
// 生成随机code_verifier
|
||||
function generateCodeVerifier() {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return btoa(String.fromCharCode.apply(null, array))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
// 生成code_challenge
|
||||
async function generateCodeChallenge(verifier) {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return btoa(String.fromCharCode.apply(null, new Uint8Array(digest)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: 重定向用户到授权页面
|
||||
|
||||
```javascript
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||
|
||||
// 保存code_verifier到本地存储
|
||||
localStorage.setItem('oauth_code_verifier', codeVerifier);
|
||||
|
||||
// 构建授权URL
|
||||
const authUrl = new URL('https://your-domain.com/api/oauth/authorize');
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('client_id', 'your_client_id');
|
||||
authUrl.searchParams.set('redirect_uri', 'https://your-app.com/callback');
|
||||
authUrl.searchParams.set('scope', 'api:read api:write');
|
||||
authUrl.searchParams.set('state', 'random_state_value');
|
||||
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
// 重定向到授权页面
|
||||
window.location.href = authUrl.toString();
|
||||
```
|
||||
|
||||
### Step 3: 处理授权回调
|
||||
|
||||
用户授权后会跳转到`https://your-app.com/callback?code=xxx&state=xxx`
|
||||
|
||||
```javascript
|
||||
// 在callback页面处理授权码
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const state = urlParams.get('state');
|
||||
const codeVerifier = localStorage.getItem('oauth_code_verifier');
|
||||
|
||||
if (code && codeVerifier) {
|
||||
// 交换访问令牌
|
||||
const tokenResponse = await fetch('https://your-domain.com/api/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: 'your_client_id',
|
||||
client_secret: 'your_client_secret',
|
||||
code: code,
|
||||
redirect_uri: 'https://your-app.com/callback',
|
||||
code_verifier: codeVerifier
|
||||
})
|
||||
});
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
// 解析JWT令牌获取用户信息
|
||||
const userInfo = parseJWTToken(tokens.access_token);
|
||||
console.log('用户信息:', userInfo);
|
||||
|
||||
// 保存令牌和用户信息
|
||||
localStorage.setItem('access_token', tokens.access_token);
|
||||
localStorage.setItem('refresh_token', tokens.refresh_token);
|
||||
localStorage.setItem('user_info', JSON.stringify(userInfo));
|
||||
|
||||
// 清理临时数据
|
||||
localStorage.removeItem('oauth_code_verifier');
|
||||
|
||||
// 跳转到应用首页
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: JWT令牌解析和用户信息获取
|
||||
|
||||
授权码流程返回的`access_token`是一个JWT令牌,包含用户信息:
|
||||
|
||||
```javascript
|
||||
// JWT令牌解析函数
|
||||
function parseJWTToken(token) {
|
||||
try {
|
||||
// JWT格式: header.payload.signature
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid JWT token format');
|
||||
}
|
||||
|
||||
// 解码payload部分
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
|
||||
// 提取用户信息
|
||||
return {
|
||||
userId: payload.sub, // 用户ID
|
||||
username: payload.preferred_username || payload.sub,
|
||||
email: payload.email, // 用户邮箱
|
||||
name: payload.name, // 用户姓名
|
||||
roles: payload.scope?.split(' ') || [], // 权限范围
|
||||
groups: payload.groups || [], // 用户组
|
||||
exp: payload.exp, // 过期时间
|
||||
iat: payload.iat, // 签发时间
|
||||
iss: payload.iss, // 签发者
|
||||
aud: payload.aud // 受众
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to parse JWT token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// JWT令牌验证函数
|
||||
function validateJWTToken(token) {
|
||||
const userInfo = parseJWTToken(token);
|
||||
if (!userInfo) return false;
|
||||
|
||||
// 检查令牌是否过期
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (userInfo.exp && now >= userInfo.exp) {
|
||||
console.log('JWT token has expired');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取用户信息示例
|
||||
async function getUserInfoFromToken() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) return null;
|
||||
|
||||
if (!validateJWTToken(token)) {
|
||||
// 令牌无效或过期,尝试刷新
|
||||
const newToken = await refreshToken();
|
||||
if (newToken) {
|
||||
return parseJWTToken(newToken);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseJWTToken(token);
|
||||
}
|
||||
```
|
||||
|
||||
**JWT令牌示例内容:**
|
||||
```json
|
||||
{
|
||||
"sub": "user123", // 用户唯一标识
|
||||
"preferred_username": "john_doe", // 用户名
|
||||
"email": "john@example.com", // 邮箱
|
||||
"name": "John Doe", // 真实姓名
|
||||
"scope": "api:read api:write", // 权限范围
|
||||
"groups": ["users", "developers"], // 用户组
|
||||
"iss": "https://your-domain.com", // 签发者
|
||||
"aud": "your_client_id", // 受众
|
||||
"exp": 1609459200, // 过期时间戳
|
||||
"iat": 1609455600, // 签发时间戳
|
||||
"jti": "token-unique-id" // 令牌唯一ID
|
||||
}
|
||||
```
|
||||
|
||||
## 👤 自动创建用户登录流程
|
||||
|
||||
### 用户信息收集和自动创建
|
||||
|
||||
当启用了`AutoCreateUser`选项时,用户首次通过OAuth2授权后会自动创建账户:
|
||||
|
||||
```javascript
|
||||
// 用户信息收集表单
|
||||
function showUserInfoForm(jwtUserInfo) {
|
||||
const formHTML = `
|
||||
<div id="userInfoForm" style="max-width: 400px; margin: 20px auto; padding: 20px; border: 1px solid #ddd; border-radius: 8px;">
|
||||
<h3>完善用户信息</h3>
|
||||
<p>系统将为您自动创建账户,请填写或确认以下信息:</p>
|
||||
|
||||
<form id="userRegistrationForm">
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label>用户名 <span style="color: red;">*</span></label>
|
||||
<input type="text" id="username" value="${jwtUserInfo.username || ''}" required
|
||||
style="width: 100%; padding: 8px; margin-top: 5px;">
|
||||
<small>用于登录的用户名</small>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label>显示名称</label>
|
||||
<input type="text" id="displayName" value="${jwtUserInfo.name || jwtUserInfo.username || ''}"
|
||||
style="width: 100%; padding: 8px; margin-top: 5px;">
|
||||
<small>在界面上显示的名称</small>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label>邮箱地址</label>
|
||||
<input type="email" id="email" value="${jwtUserInfo.email || ''}"
|
||||
style="width: 100%; padding: 8px; margin-top: 5px;">
|
||||
<small>用于接收通知和找回密码</small>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label>所属组织</label>
|
||||
<input type="text" id="group" value="oauth2" readonly
|
||||
style="width: 100%; padding: 8px; margin-top: 5px; background: #f5f5f5;">
|
||||
<small>OAuth2自动创建的用户组</small>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h4>从OAuth2提供商获取的信息:</h4>
|
||||
<pre style="background: #f8f9fa; padding: 10px; border-radius: 4px; font-size: 12px;">
|
||||
${JSON.stringify(jwtUserInfo, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<button type="submit" style="background: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer;">
|
||||
创建账户并登录
|
||||
</button>
|
||||
<button type="button" onclick="cancelRegistration()" style="background: #6c757d; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; margin-left: 10px;">
|
||||
取消
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.innerHTML = formHTML;
|
||||
|
||||
// 绑定表单提交事件
|
||||
document.getElementById('userRegistrationForm').addEventListener('submit', handleUserRegistration);
|
||||
}
|
||||
|
||||
// 处理用户注册
|
||||
async function handleUserRegistration(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = {
|
||||
username: document.getElementById('username').value.trim(),
|
||||
displayName: document.getElementById('displayName').value.trim(),
|
||||
email: document.getElementById('email').value.trim(),
|
||||
group: document.getElementById('group').value,
|
||||
oauth2Provider: 'oauth2',
|
||||
oauth2UserId: parseJWTToken(localStorage.getItem('access_token')).userId
|
||||
};
|
||||
|
||||
try {
|
||||
// 调用自动创建用户API
|
||||
const response = await fetch('https://your-domain.com/api/oauth/auto_create_user', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// 用户创建成功,跳转到主界面
|
||||
localStorage.setItem('user_created', 'true');
|
||||
window.location.href = '/dashboard';
|
||||
} else {
|
||||
alert('创建用户失败: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('用户创建失败:', error);
|
||||
alert('创建用户时发生错误,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
// 取消注册
|
||||
function cancelRegistration() {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.location.href = '/';
|
||||
}
|
||||
```
|
||||
|
||||
### 完整的自动登录流程
|
||||
|
||||
```javascript
|
||||
// 改进的自动登录初始化
|
||||
async function initAutoLogin() {
|
||||
try {
|
||||
// 1. 检查是否有有效的访问令牌
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
if (!accessToken || !validateJWTToken(accessToken)) {
|
||||
// 没有有效令牌,开始OAuth2授权流程
|
||||
startOAuth2Authorization();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 解析JWT令牌获取用户信息
|
||||
const jwtUserInfo = parseJWTToken(accessToken);
|
||||
console.log('JWT用户信息:', jwtUserInfo);
|
||||
|
||||
// 3. 检查用户是否已存在于系统中
|
||||
const userExists = await checkUserExists(jwtUserInfo.userId);
|
||||
|
||||
if (!userExists && !localStorage.getItem('user_created')) {
|
||||
// 4. 用户不存在且未创建,显示用户信息收集表单
|
||||
showUserInfoForm(jwtUserInfo);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 用户已存在或已创建,直接登录
|
||||
const apiUserInfo = await oauth2Client.callAPI('/api/user/self');
|
||||
console.log('API用户信息:', apiUserInfo);
|
||||
|
||||
// 6. 显示主界面
|
||||
showDashboard(jwtUserInfo, apiUserInfo);
|
||||
|
||||
} catch (error) {
|
||||
console.error('自动登录失败:', error);
|
||||
// 清理令牌并重新开始授权流程
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user_created');
|
||||
startOAuth2Authorization();
|
||||
}
|
||||
}
|
||||
|
||||
// 检查用户是否存在
|
||||
async function checkUserExists(userId) {
|
||||
try {
|
||||
const response = await fetch(`https://your-domain.com/api/oauth/user_exists/${userId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
return result.exists;
|
||||
} catch (error) {
|
||||
console.error('检查用户存在性失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 开始OAuth2授权流程
|
||||
function startOAuth2Authorization() {
|
||||
const oauth2Client = new OAuth2Client({
|
||||
clientId: 'your_client_id',
|
||||
clientSecret: 'your_client_secret',
|
||||
serverUrl: 'https://your-domain.com',
|
||||
redirectUri: window.location.origin + '/callback',
|
||||
scopes: 'api:read api:write'
|
||||
});
|
||||
|
||||
oauth2Client.startAuthorizationCodeFlow();
|
||||
}
|
||||
```
|
||||
|
||||
### 服务器端自动创建用户API
|
||||
|
||||
需要在服务器端实现相应的API端点:
|
||||
|
||||
```go
|
||||
// 用户存在性检查
|
||||
func CheckUserExists(c *gin.Context) {
|
||||
oauthUserId := c.Param("oauth_user_id")
|
||||
|
||||
var user model.User
|
||||
err := model.DB.Where("oauth2_user_id = ?", oauthUserId).First(&user).Error
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"exists": false,
|
||||
})
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Database error",
|
||||
})
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"exists": true,
|
||||
"user_id": user.Id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 自动创建用户
|
||||
func AutoCreateUser(c *gin.Context) {
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
if !settings.AutoCreateUser {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "自动创建用户功能未启用",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Group string `json:"group"`
|
||||
OAuth2UserId string `json:"oauth2UserId" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的请求参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户是否已存在
|
||||
var existingUser model.User
|
||||
err := model.DB.Where("username = ? OR oauth2_user_id = ?", req.Username, req.OAuth2UserId).First(&existingUser).Error
|
||||
if err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"success": false,
|
||||
"message": "用户已存在",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建新用户
|
||||
user := model.User{
|
||||
Username: req.Username,
|
||||
DisplayName: req.DisplayName,
|
||||
Email: req.Email,
|
||||
Group: settings.DefaultUserGroup,
|
||||
Role: settings.DefaultUserRole,
|
||||
Status: 1,
|
||||
Password: common.GenerateRandomString(32), // 随机密码,用户通过OAuth2登录
|
||||
OAuth2UserId: req.OAuth2UserId,
|
||||
}
|
||||
|
||||
if req.DisplayName == "" {
|
||||
user.DisplayName = req.Username
|
||||
}
|
||||
|
||||
if user.Group == "" {
|
||||
user.Group = "oauth2"
|
||||
}
|
||||
|
||||
err = user.Insert(0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "创建用户失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "用户创建成功",
|
||||
"user_id": user.Id,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 自动登录实现
|
||||
|
||||
### 令牌刷新机制
|
||||
|
||||
```javascript
|
||||
async function refreshToken() {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (!refreshToken) {
|
||||
// 重新授权
|
||||
redirectToAuth();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://your-domain.com/api/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: 'your_client_id',
|
||||
client_secret: 'your_client_secret',
|
||||
refresh_token: refreshToken
|
||||
})
|
||||
});
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
if (tokens.access_token) {
|
||||
localStorage.setItem('access_token', tokens.access_token);
|
||||
if (tokens.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokens.refresh_token);
|
||||
}
|
||||
return tokens.access_token;
|
||||
}
|
||||
} catch (error) {
|
||||
// 刷新失败,重新授权
|
||||
redirectToAuth();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 自动认证拦截器
|
||||
|
||||
```javascript
|
||||
class OAuth2Client {
|
||||
constructor(clientId, clientSecret, baseURL) {
|
||||
this.clientId = clientId;
|
||||
this.clientSecret = clientSecret;
|
||||
this.baseURL = baseURL;
|
||||
}
|
||||
|
||||
// 自动处理认证的请求方法
|
||||
async request(url, options = {}) {
|
||||
let accessToken = localStorage.getItem('access_token');
|
||||
|
||||
// 检查令牌是否即将过期
|
||||
if (this.isTokenExpiringSoon(accessToken)) {
|
||||
accessToken = await this.refreshToken();
|
||||
}
|
||||
|
||||
// 添加认证头
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseURL}${url}`, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
|
||||
// 如果401,尝试刷新令牌
|
||||
if (response.status === 401) {
|
||||
accessToken = await this.refreshToken();
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
|
||||
// 重试请求
|
||||
return fetch(`${this.baseURL}${url}`, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Request failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查令牌是否即将过期
|
||||
isTokenExpiringSoon(token) {
|
||||
if (!token) return true;
|
||||
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return true;
|
||||
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
const exp = payload.exp * 1000; // 转换为毫秒
|
||||
const now = Date.now();
|
||||
return exp - now < 5 * 60 * 1000; // 5分钟内过期
|
||||
} catch (error) {
|
||||
console.error('Token validation failed:', error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前用户信息
|
||||
getCurrentUser() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token || !this.validateJWTToken(token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.parseJWTToken(token);
|
||||
}
|
||||
|
||||
// 解析JWT令牌
|
||||
parseJWTToken(token) {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid JWT token format');
|
||||
}
|
||||
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
|
||||
return {
|
||||
userId: payload.sub,
|
||||
username: payload.preferred_username || payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
roles: payload.scope?.split(' ') || [],
|
||||
groups: payload.groups || [],
|
||||
exp: payload.exp,
|
||||
iat: payload.iat,
|
||||
iss: payload.iss,
|
||||
aud: payload.aud
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to parse JWT token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 验证JWT令牌
|
||||
validateJWTToken(token) {
|
||||
const userInfo = this.parseJWTToken(token);
|
||||
if (!userInfo) return false;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (userInfo.exp && now >= userInfo.exp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
async getUserInfo() {
|
||||
const response = await this.request('/api/user/self');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 调用API示例
|
||||
async callAPI(endpoint, data = null) {
|
||||
const options = data ? {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
} : { method: 'GET' };
|
||||
|
||||
const response = await this.request(endpoint, options);
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```javascript
|
||||
// 初始化OAuth2客户端
|
||||
const oauth2Client = new OAuth2Client(
|
||||
'your_client_id',
|
||||
'your_client_secret',
|
||||
'https://your-domain.com'
|
||||
);
|
||||
|
||||
// 应用启动时自动检查登录状态
|
||||
async function initApp() {
|
||||
try {
|
||||
// 尝试获取用户信息(会自动处理令牌刷新)
|
||||
const userInfo = await oauth2Client.getUserInfo();
|
||||
console.log('User logged in:', userInfo);
|
||||
|
||||
// 显示用户界面
|
||||
showDashboard(userInfo);
|
||||
} catch (error) {
|
||||
// 用户未登录,重定向到授权页面
|
||||
redirectToAuth();
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时初始化
|
||||
document.addEventListener('DOMContentLoaded', initApp);
|
||||
```
|
||||
|
||||
## 🛡️ 安全最佳实践
|
||||
|
||||
### 1. HTTPS 必需
|
||||
```
|
||||
生产环境必须使用HTTPS
|
||||
重定向URI必须使用https://(本地开发可用http://localhost)
|
||||
```
|
||||
|
||||
### 2. 状态参数验证
|
||||
```javascript
|
||||
// 发起授权时
|
||||
const state = crypto.randomUUID();
|
||||
localStorage.setItem('oauth_state', state);
|
||||
|
||||
// 回调时验证
|
||||
const returnedState = urlParams.get('state');
|
||||
const savedState = localStorage.getItem('oauth_state');
|
||||
if (returnedState !== savedState) {
|
||||
throw new Error('State mismatch - possible CSRF attack');
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 令牌安全存储
|
||||
```javascript
|
||||
// 使用HttpOnly Cookie(推荐)
|
||||
// 或加密存储在localStorage
|
||||
function secureStorage() {
|
||||
return {
|
||||
setItem: (key, value) => {
|
||||
const encrypted = encrypt(value); // 使用加密
|
||||
localStorage.setItem(key, encrypted);
|
||||
},
|
||||
getItem: (key) => {
|
||||
const encrypted = localStorage.getItem(key);
|
||||
return encrypted ? decrypt(encrypted) : null;
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 📚 完整示例项目
|
||||
|
||||
创建一个完整的单页应用示例:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OAuth2 Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="login-section">
|
||||
<h1>请登录</h1>
|
||||
<button onclick="login()">使用OAuth2登录</button>
|
||||
</div>
|
||||
|
||||
<div id="app-section" style="display:none">
|
||||
<h1>欢迎!</h1>
|
||||
<div id="user-info"></div>
|
||||
<button onclick="logout()">登出</button>
|
||||
<button onclick="testAPI()">测试API调用</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 这里包含上面的所有OAuth2Client代码
|
||||
|
||||
const oauth2Client = new OAuth2Client(
|
||||
'your_client_id',
|
||||
'your_client_secret',
|
||||
'https://your-domain.com'
|
||||
);
|
||||
|
||||
async function login() {
|
||||
// 实现授权码流程...
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
localStorage.clear();
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function testAPI() {
|
||||
try {
|
||||
const result = await oauth2Client.callAPI('/api/user/self');
|
||||
alert('API调用成功: ' + JSON.stringify(result));
|
||||
} catch (error) {
|
||||
alert('API调用失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化应用
|
||||
initApp();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## 🔍 调试和测试
|
||||
|
||||
### 验证JWT令牌
|
||||
访问 [jwt.io](https://jwt.io) 解析令牌内容:
|
||||
```
|
||||
Header: {"alg":"RS256","typ":"JWT","kid":"oauth2-key-1"}
|
||||
Payload: {"sub":"user_id","aud":"your_client_id","exp":1234567890}
|
||||
```
|
||||
|
||||
### 查看服务器信息
|
||||
```bash
|
||||
curl https://your-domain.com/.well-known/oauth-authorization-server
|
||||
```
|
||||
|
||||
### 获取JWKS公钥
|
||||
```bash
|
||||
curl https://your-domain.com/.well-known/jwks.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
这个demo涵盖了OAuth2服务器的完整使用流程,实现了真正的自动登录功能。用户只需要第一次授权,之后应用会自动处理令牌刷新和API认证。
|
||||
258
docs/oauth2_setup.md
Normal file
258
docs/oauth2_setup.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# OAuth2 服务器设置指南
|
||||
|
||||
## 概述
|
||||
|
||||
该 OAuth2 服务器实现基于 RFC 6749 标准,支持以下特性:
|
||||
|
||||
- **授权类型**: Client Credentials, Authorization Code + PKCE, Refresh Token
|
||||
- **JWT 访问令牌**: 使用 RS256 签名
|
||||
- **JWKS 端点**: 公钥自动发布和轮换
|
||||
- **兼容性**: 与现有认证系统完全兼容
|
||||
|
||||
## 配置
|
||||
|
||||
### 1. 环境变量配置
|
||||
|
||||
在 `.env` 文件中添加以下配置:
|
||||
|
||||
```env
|
||||
# OAuth2 基础配置
|
||||
OAUTH2_ENABLED=true
|
||||
OAUTH2_ISSUER=https://your-domain.com
|
||||
OAUTH2_ACCESS_TOKEN_TTL=10
|
||||
OAUTH2_REFRESH_TOKEN_TTL=720
|
||||
|
||||
# JWT 签名配置
|
||||
JWT_SIGNING_ALGORITHM=RS256
|
||||
JWT_KEY_ID=oauth2-key-1
|
||||
JWT_PRIVATE_KEY_FILE=/path/to/private-key.pem
|
||||
|
||||
# 授权类型(逗号分隔)
|
||||
OAUTH2_ALLOWED_GRANT_TYPES=client_credentials,authorization_code,refresh_token
|
||||
|
||||
# 强制 PKCE
|
||||
OAUTH2_REQUIRE_PKCE=true
|
||||
|
||||
# 自动创建用户
|
||||
OAUTH2_AUTO_CREATE_USER=false
|
||||
OAUTH2_DEFAULT_USER_ROLE=1
|
||||
OAUTH2_DEFAULT_USER_GROUP=default
|
||||
```
|
||||
|
||||
### 2. 数据库迁移
|
||||
|
||||
重启应用程序将自动创建 `oauth_clients` 表。
|
||||
|
||||
### 3. 创建第一个 OAuth2 客户端
|
||||
|
||||
通过管理员界面或 API 创建客户端:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/oauth_clients \
|
||||
-H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "测试服务",
|
||||
"client_type": "confidential",
|
||||
"grant_types": ["client_credentials"],
|
||||
"scopes": ["api:read", "api:write"],
|
||||
"description": "用于服务对服务认证的测试客户端"
|
||||
}'
|
||||
```
|
||||
|
||||
## OAuth2 端点
|
||||
|
||||
### 标准端点
|
||||
|
||||
- **令牌端点**: `POST /api/oauth/token`
|
||||
- **授权端点**: `GET /api/oauth/authorize`
|
||||
- **JWKS 端点**: `GET /.well-known/jwks.json`
|
||||
- **服务器信息**: `GET /.well-known/oauth-authorization-server`
|
||||
|
||||
### 管理端点
|
||||
|
||||
- **令牌内省**: `POST /api/oauth/introspect` (需要管理员权限)
|
||||
- **令牌撤销**: `POST /api/oauth/revoke`
|
||||
|
||||
### 客户端管理端点
|
||||
|
||||
- **列出客户端**: `GET /api/oauth_clients`
|
||||
- **创建客户端**: `POST /api/oauth_clients`
|
||||
- **更新客户端**: `PUT /api/oauth_clients`
|
||||
- **删除客户端**: `DELETE /api/oauth_clients/{id}`
|
||||
- **重新生成密钥**: `POST /api/oauth_clients/{id}/regenerate_secret`
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 1. Client Credentials 流程
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := clientcredentials.Config{
|
||||
ClientID: "your_client_id",
|
||||
ClientSecret: "your_client_secret",
|
||||
TokenURL: "https://your-domain.com/api/oauth/token",
|
||||
Scopes: []string{"api:read"},
|
||||
}
|
||||
|
||||
client := cfg.Client(context.Background())
|
||||
resp, _ := client.Get("https://your-domain.com/api/protected")
|
||||
// 处理响应...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Authorization Code + PKCE 流程
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
conf := oauth2.Config{
|
||||
ClientID: "your_web_client_id",
|
||||
ClientSecret: "your_web_client_secret",
|
||||
RedirectURL: "https://your-app.com/callback",
|
||||
Scopes: []string{"api:read"},
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: "https://your-domain.com/api/oauth/authorize",
|
||||
TokenURL: "https://your-domain.com/api/oauth/token",
|
||||
},
|
||||
}
|
||||
|
||||
// 生成 PKCE 参数
|
||||
verifier := oauth2.GenerateVerifier()
|
||||
|
||||
// 构建授权 URL
|
||||
url := conf.AuthCodeURL("state", oauth2.S256ChallengeOption(verifier))
|
||||
|
||||
// 用户授权后,使用授权码交换令牌
|
||||
token, _ := conf.Exchange(context.Background(), code, oauth2.VerifierOption(verifier))
|
||||
|
||||
// 使用令牌调用 API
|
||||
client := conf.Client(context.Background(), token)
|
||||
resp, _ := client.Get("https://your-domain.com/api/protected")
|
||||
}
|
||||
```
|
||||
|
||||
### 3. cURL 示例
|
||||
|
||||
```bash
|
||||
# 获取访问令牌
|
||||
curl -X POST https://your-domain.com/api/oauth/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-u "client_id:client_secret" \
|
||||
-d "grant_type=client_credentials&scope=api:read"
|
||||
|
||||
# 使用访问令牌调用 API
|
||||
curl -H "Authorization: Bearer ACCESS_TOKEN" \
|
||||
https://your-domain.com/api/status
|
||||
```
|
||||
|
||||
## 安全建议
|
||||
|
||||
### 1. 密钥管理
|
||||
|
||||
- 使用强随机密钥生成器
|
||||
- 定期轮换 RSA 密钥对
|
||||
- 将私钥存储在安全位置
|
||||
- 考虑使用 HSM 或密钥管理服务
|
||||
|
||||
### 2. 网络安全
|
||||
|
||||
- 强制使用 HTTPS
|
||||
- 配置适当的 CORS 策略
|
||||
- 实现速率限制
|
||||
- 启用请求日志和监控
|
||||
|
||||
### 3. 客户端管理
|
||||
|
||||
- 定期审查客户端列表
|
||||
- 撤销不再使用的客户端
|
||||
- 监控客户端使用情况
|
||||
- 为不同用途创建不同的客户端
|
||||
|
||||
### 4. Scope 和权限
|
||||
|
||||
- 实施最小权限原则
|
||||
- 定期审查 scope 定义
|
||||
- 为敏感操作创建特殊 scope
|
||||
- 实现细粒度的权限控制
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **"OAuth2 server is disabled"**
|
||||
- 确保 `OAUTH2_ENABLED=true`
|
||||
- 检查配置文件是否正确加载
|
||||
|
||||
2. **"invalid_client"**
|
||||
- 验证 client_id 和 client_secret
|
||||
- 确保客户端状态为启用
|
||||
|
||||
3. **"invalid_grant"**
|
||||
- 检查授权类型是否被允许
|
||||
- 验证 PKCE 参数(如果启用)
|
||||
|
||||
4. **"invalid_scope"**
|
||||
- 确保请求的 scope 在客户端配置中
|
||||
- 检查 scope 格式(空格分隔)
|
||||
|
||||
### 调试
|
||||
|
||||
启用详细日志:
|
||||
|
||||
```env
|
||||
GIN_MODE=debug
|
||||
LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
检查 JWKS 端点:
|
||||
|
||||
```bash
|
||||
curl https://your-domain.com/.well-known/jwks.json
|
||||
```
|
||||
|
||||
验证令牌:
|
||||
|
||||
```bash
|
||||
# 可以使用 https://jwt.io 解码和验证 JWT 令牌
|
||||
```
|
||||
|
||||
## 生产部署
|
||||
|
||||
### 1. 负载均衡
|
||||
|
||||
- OAuth2 服务器是无状态的,支持水平扩展
|
||||
- 确保所有实例使用相同的私钥
|
||||
- 使用 Redis 作为令牌存储
|
||||
|
||||
### 2. 监控
|
||||
|
||||
- 监控令牌签发速率
|
||||
- 跟踪客户端使用情况
|
||||
- 设置异常告警
|
||||
|
||||
### 3. 备份
|
||||
|
||||
- 备份私钥文件
|
||||
- 备份客户端配置数据
|
||||
- 制定灾难恢复计划
|
||||
|
||||
### 4. 性能优化
|
||||
|
||||
- 启用 JWKS 缓存
|
||||
- 使用连接池
|
||||
- 优化数据库查询
|
||||
- 考虑使用 CDN 分发 JWKS
|
||||
125
examples/oauth2_test_client.go
Normal file
125
examples/oauth2_test_client.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 测试 Client Credentials 流程
|
||||
testClientCredentials()
|
||||
|
||||
// 测试 Authorization Code + PKCE 流程(需要浏览器交互)
|
||||
// testAuthorizationCode()
|
||||
}
|
||||
|
||||
// testClientCredentials 测试服务对服务认证
|
||||
func testClientCredentials() {
|
||||
fmt.Println("=== Testing Client Credentials Flow ===")
|
||||
|
||||
cfg := clientcredentials.Config{
|
||||
ClientID: "client_demo123456789", // 需要先创建客户端
|
||||
ClientSecret: "demo_secret_32_chars_long_123456",
|
||||
TokenURL: "http://127.0.0.1:8080/api/oauth/token",
|
||||
Scopes: []string{"api:read", "api:write"},
|
||||
EndpointParams: map[string][]string{
|
||||
"audience": {"api://new-api"},
|
||||
},
|
||||
}
|
||||
|
||||
// 创建HTTP客户端
|
||||
httpClient := cfg.Client(context.Background())
|
||||
|
||||
// 调用受保护的API
|
||||
resp, err := httpClient.Get("http://127.0.0.1:8080/api/status")
|
||||
if err != nil {
|
||||
log.Printf("Request failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read response: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Status: %s\n", resp.Status)
|
||||
fmt.Printf("Response: %s\n", string(body))
|
||||
}
|
||||
|
||||
// testAuthorizationCode 测试授权码流程
|
||||
func testAuthorizationCode() {
|
||||
fmt.Println("=== Testing Authorization Code + PKCE Flow ===")
|
||||
|
||||
conf := oauth2.Config{
|
||||
ClientID: "client_web123456789", // Web客户端
|
||||
ClientSecret: "web_secret_32_chars_long_123456",
|
||||
RedirectURL: "http://localhost:9999/callback",
|
||||
Scopes: []string{"api:read", "api:write"},
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: "http://127.0.0.1:8080/api/oauth/authorize",
|
||||
TokenURL: "http://127.0.0.1:8080/api/oauth/token",
|
||||
},
|
||||
}
|
||||
|
||||
// 生成PKCE参数
|
||||
codeVerifier := oauth2.GenerateVerifier()
|
||||
|
||||
// 构建授权URL
|
||||
url := conf.AuthCodeURL(
|
||||
"random-state-string",
|
||||
oauth2.S256ChallengeOption(codeVerifier),
|
||||
oauth2.SetAuthURLParam("audience", "api://new-api"),
|
||||
)
|
||||
|
||||
fmt.Printf("Visit this URL to authorize:\n%s\n\n", url)
|
||||
fmt.Printf("After authorization, you'll get a code. Use it to exchange for tokens.\n")
|
||||
|
||||
// 在实际应用中,这里需要启动一个HTTP服务器来接收回调
|
||||
// 或者手动输入从回调URL中获取的授权码
|
||||
|
||||
fmt.Print("Enter the authorization code: ")
|
||||
var code string
|
||||
fmt.Scanln(&code)
|
||||
|
||||
if code != "" {
|
||||
// 交换令牌
|
||||
token, err := conf.Exchange(
|
||||
context.Background(),
|
||||
code,
|
||||
oauth2.VerifierOption(codeVerifier),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Token exchange failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Access Token: %s\n", token.AccessToken)
|
||||
fmt.Printf("Token Type: %s\n", token.TokenType)
|
||||
fmt.Printf("Expires In: %v\n", token.Expiry)
|
||||
|
||||
// 使用令牌调用API
|
||||
client := conf.Client(context.Background(), token)
|
||||
resp, err := client.Get("http://127.0.0.1:8080/api/status")
|
||||
if err != nil {
|
||||
log.Printf("API request failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read response: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("API Response: %s\n", string(body))
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
257
middleware/oauth_jwt.go
Normal file
257
middleware/oauth_jwt.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
"one-api/setting/system_setting"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// OAuthJWTAuth OAuth2 JWT认证中间件
|
||||
func OAuthJWTAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 检查OAuth2是否启用
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
if !settings.Enabled {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 获取Authorization header
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.Next() // 没有Authorization header,继续到下一个中间件
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否为Bearer token
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
c.Next() // 不是Bearer token,继续到下一个中间件
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenString == "" {
|
||||
abortWithOAuthError(c, "invalid_token", "Missing token")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证JWT token
|
||||
claims, err := validateOAuthJWT(tokenString)
|
||||
if err != nil {
|
||||
abortWithOAuthError(c, "invalid_token", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 验证token的有效性
|
||||
if err := validateOAuthClaims(claims); err != nil {
|
||||
abortWithOAuthError(c, "invalid_token", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 设置上下文信息
|
||||
setOAuthContext(c, claims)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// validateOAuthJWT 验证OAuth2 JWT令牌
|
||||
func validateOAuthJWT(tokenString string) (jwt.MapClaims, error) {
|
||||
// 解析JWT而不验证签名(先获取header中的kid)
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// 检查签名方法
|
||||
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
// 获取kid
|
||||
kid, ok := token.Header["kid"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing kid in token header")
|
||||
}
|
||||
|
||||
// 根据kid获取公钥
|
||||
publicKey, err := getPublicKeyByKid(kid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
|
||||
return publicKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token: %w", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// getPublicKeyByKid 根据kid获取公钥
|
||||
func getPublicKeyByKid(kid string) (*rsa.PublicKey, error) {
|
||||
// 这里需要从JWKS获取公钥
|
||||
// 在实际实现中,你可能需要从OAuth server获取JWKS
|
||||
// 这里先实现一个简单版本
|
||||
|
||||
// TODO: 实现JWKS缓存和刷新机制
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
if settings.JWTKeyID == kid {
|
||||
// 从OAuth server模块获取公钥
|
||||
// 这需要在OAuth server初始化后才能使用
|
||||
return nil, fmt.Errorf("JWKS functionality not yet implemented")
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown kid: %s", kid)
|
||||
}
|
||||
|
||||
// validateOAuthClaims 验证OAuth2 claims
|
||||
func validateOAuthClaims(claims jwt.MapClaims) error {
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
|
||||
// 验证issuer
|
||||
if iss, ok := claims["iss"].(string); ok {
|
||||
if iss != settings.Issuer {
|
||||
return fmt.Errorf("invalid issuer")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("missing issuer claim")
|
||||
}
|
||||
|
||||
// 验证audience
|
||||
// if aud, ok := claims["aud"].(string); ok {
|
||||
// // TODO: 验证audience
|
||||
// }
|
||||
|
||||
// 验证客户端ID
|
||||
if clientID, ok := claims["client_id"].(string); ok {
|
||||
// 验证客户端是否存在且有效
|
||||
client, err := model.GetOAuthClientByID(clientID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid client")
|
||||
}
|
||||
if client.Status != common.UserStatusEnabled {
|
||||
return fmt.Errorf("client disabled")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("missing client_id claim")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setOAuthContext 设置OAuth上下文信息
|
||||
func setOAuthContext(c *gin.Context, claims jwt.MapClaims) {
|
||||
c.Set("oauth_claims", claims)
|
||||
c.Set("oauth_authenticated", true)
|
||||
|
||||
// 提取基本信息
|
||||
if clientID, ok := claims["client_id"].(string); ok {
|
||||
c.Set("oauth_client_id", clientID)
|
||||
}
|
||||
|
||||
if scope, ok := claims["scope"].(string); ok {
|
||||
c.Set("oauth_scope", scope)
|
||||
}
|
||||
|
||||
if sub, ok := claims["sub"].(string); ok {
|
||||
c.Set("oauth_subject", sub)
|
||||
}
|
||||
|
||||
// 对于client_credentials流程,subject就是client_id
|
||||
// 对于authorization_code流程,subject是用户ID
|
||||
if grantType, ok := claims["grant_type"].(string); ok {
|
||||
c.Set("oauth_grant_type", grantType)
|
||||
}
|
||||
}
|
||||
|
||||
// abortWithOAuthError 返回OAuth错误响应
|
||||
func abortWithOAuthError(c *gin.Context, errorCode, description string) {
|
||||
c.Header("WWW-Authenticate", fmt.Sprintf(`Bearer error="%s", error_description="%s"`, errorCode, description))
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": errorCode,
|
||||
"error_description": description,
|
||||
})
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
// RequireOAuthScope OAuth2 scope验证中间件
|
||||
func RequireOAuthScope(requiredScope string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 检查是否通过OAuth认证
|
||||
if !c.GetBool("oauth_authenticated") {
|
||||
abortWithOAuthError(c, "insufficient_scope", "OAuth2 authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取token的scope
|
||||
scope, exists := c.Get("oauth_scope")
|
||||
if !exists {
|
||||
abortWithOAuthError(c, "insufficient_scope", "No scope in token")
|
||||
return
|
||||
}
|
||||
|
||||
scopeStr, ok := scope.(string)
|
||||
if !ok {
|
||||
abortWithOAuthError(c, "insufficient_scope", "Invalid scope format")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否包含所需的scope
|
||||
scopes := strings.Split(scopeStr, " ")
|
||||
for _, s := range scopes {
|
||||
if strings.TrimSpace(s) == requiredScope {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
abortWithOAuthError(c, "insufficient_scope", fmt.Sprintf("Required scope: %s", requiredScope))
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalOAuthAuth 可选的OAuth认证中间件(不会阻止请求)
|
||||
func OptionalOAuthAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 尝试OAuth认证,但不会阻止请求
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if claims, err := validateOAuthJWT(tokenString); err == nil {
|
||||
if validateOAuthClaims(claims) == nil {
|
||||
setOAuthContext(c, claims)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetOAuthClaims 获取OAuth claims
|
||||
func GetOAuthClaims(c *gin.Context) (jwt.MapClaims, bool) {
|
||||
claims, exists := c.Get("oauth_claims")
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
mapClaims, ok := claims.(jwt.MapClaims)
|
||||
return mapClaims, ok
|
||||
}
|
||||
|
||||
// IsOAuthAuthenticated 检查是否通过OAuth认证
|
||||
func IsOAuthAuthenticated(c *gin.Context) bool {
|
||||
return c.GetBool("oauth_authenticated")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -31,6 +31,19 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.GET("/oauth/oidc", middleware.CriticalRateLimit(), controller.OidcAuth)
|
||||
apiRouter.GET("/oauth/linuxdo", middleware.CriticalRateLimit(), controller.LinuxdoOAuth)
|
||||
apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode)
|
||||
|
||||
// OAuth2 Server endpoints
|
||||
apiRouter.GET("/.well-known/jwks.json", controller.GetJWKS)
|
||||
apiRouter.GET("/.well-known/oauth-authorization-server", controller.OAuthServerInfo)
|
||||
apiRouter.POST("/oauth/token", middleware.CriticalRateLimit(), controller.OAuthTokenEndpoint)
|
||||
apiRouter.GET("/oauth/authorize", controller.OAuthAuthorizeEndpoint)
|
||||
apiRouter.POST("/oauth/introspect", middleware.AdminAuth(), controller.OAuthIntrospect)
|
||||
apiRouter.POST("/oauth/revoke", middleware.CriticalRateLimit(), controller.OAuthRevoke)
|
||||
|
||||
// OAuth2 管理API (前端使用)
|
||||
apiRouter.GET("/oauth/jwks", controller.GetJWKS)
|
||||
apiRouter.GET("/oauth/server-info", controller.OAuthServerInfo)
|
||||
|
||||
apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
|
||||
apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), controller.WeChatBind)
|
||||
apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind)
|
||||
@@ -234,5 +247,17 @@ func SetApiRouter(router *gin.Engine) {
|
||||
modelsRoute.PUT("/", controller.UpdateModelMeta)
|
||||
modelsRoute.DELETE("/:id", controller.DeleteModelMeta)
|
||||
}
|
||||
|
||||
oauthClientsRoute := apiRouter.Group("/oauth_clients")
|
||||
oauthClientsRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
oauthClientsRoute.GET("/", controller.GetAllOAuthClients)
|
||||
oauthClientsRoute.GET("/search", controller.SearchOAuthClients)
|
||||
oauthClientsRoute.GET("/:id", controller.GetOAuthClient)
|
||||
oauthClientsRoute.POST("/", controller.CreateOAuthClient)
|
||||
oauthClientsRoute.PUT("/", controller.UpdateOAuthClient)
|
||||
oauthClientsRoute.DELETE("/:id", controller.DeleteOAuthClient)
|
||||
oauthClientsRoute.POST("/:id/regenerate_secret", controller.RegenerateOAuthClientSecret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
70
setting/system_setting/oauth2.go
Normal file
70
setting/system_setting/oauth2.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package system_setting
|
||||
|
||||
import "one-api/setting/config"
|
||||
|
||||
type OAuth2Settings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Issuer string `json:"issuer"`
|
||||
AccessTokenTTL int `json:"access_token_ttl"` // in minutes
|
||||
RefreshTokenTTL int `json:"refresh_token_ttl"` // in minutes
|
||||
AllowedGrantTypes []string `json:"allowed_grant_types"` // client_credentials, authorization_code, refresh_token
|
||||
RequirePKCE bool `json:"require_pkce"` // force PKCE for authorization code flow
|
||||
JWTSigningAlgorithm string `json:"jwt_signing_algorithm"`
|
||||
JWTKeyID string `json:"jwt_key_id"`
|
||||
JWTPrivateKeyFile string `json:"jwt_private_key_file"`
|
||||
AutoCreateUser bool `json:"auto_create_user"` // auto create user on first OAuth2 login
|
||||
DefaultUserRole int `json:"default_user_role"` // default role for auto-created users
|
||||
DefaultUserGroup string `json:"default_user_group"` // default group for auto-created users
|
||||
ScopeMappings map[string][]string `json:"scope_mappings"` // scope to permissions mapping
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
var defaultOAuth2Settings = OAuth2Settings{
|
||||
Enabled: false,
|
||||
AccessTokenTTL: 10, // 10 minutes
|
||||
RefreshTokenTTL: 720, // 12 hours
|
||||
AllowedGrantTypes: []string{"client_credentials", "authorization_code", "refresh_token"},
|
||||
RequirePKCE: true,
|
||||
JWTSigningAlgorithm: "RS256",
|
||||
JWTKeyID: "oauth2-key-1",
|
||||
AutoCreateUser: false,
|
||||
DefaultUserRole: 1, // common user
|
||||
DefaultUserGroup: "default",
|
||||
ScopeMappings: map[string][]string{
|
||||
"api:read": {"read"},
|
||||
"api:write": {"write"},
|
||||
"admin": {"admin"},
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 注册到全局配置管理器
|
||||
config.GlobalConfig.Register("oauth2", &defaultOAuth2Settings)
|
||||
}
|
||||
|
||||
func GetOAuth2Settings() *OAuth2Settings {
|
||||
return &defaultOAuth2Settings
|
||||
}
|
||||
|
||||
// UpdateOAuth2Settings 更新OAuth2配置
|
||||
func UpdateOAuth2Settings(settings OAuth2Settings) {
|
||||
defaultOAuth2Settings = settings
|
||||
}
|
||||
|
||||
// ValidateGrantType 验证授权类型是否被允许
|
||||
func (s *OAuth2Settings) ValidateGrantType(grantType string) bool {
|
||||
for _, allowedType := range s.AllowedGrantTypes {
|
||||
if allowedType == grantType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetScopePermissions 获取scope对应的权限
|
||||
func (s *OAuth2Settings) GetScopePermissions(scope string) []string {
|
||||
if perms, exists := s.ScopeMappings[scope]; exists {
|
||||
return perms
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
80
src/oauth/server.go
Normal file
80
src/oauth/server.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"fmt"
|
||||
"one-api/common"
|
||||
"one-api/setting/system_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||
)
|
||||
|
||||
var (
|
||||
simplePrivateKey *rsa.PrivateKey
|
||||
simpleJWKSSet jwk.Set
|
||||
)
|
||||
|
||||
// InitOAuthServer 简化版OAuth2服务器初始化
|
||||
func InitOAuthServer() error {
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
if !settings.Enabled {
|
||||
common.SysLog("OAuth2 server is disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 生成RSA私钥(简化版本)
|
||||
var err error
|
||||
simplePrivateKey, err = rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate RSA key: %w", err)
|
||||
}
|
||||
|
||||
// 创建JWKS
|
||||
simpleJWKSSet, err = createSimpleJWKS(simplePrivateKey, settings.JWTKeyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create JWKS: %w", err)
|
||||
}
|
||||
|
||||
common.SysLog("OAuth2 server initialized successfully (simple mode)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// createSimpleJWKS 创建简单的JWKS
|
||||
func createSimpleJWKS(privateKey *rsa.PrivateKey, keyID string) (jwk.Set, error) {
|
||||
pubJWK, err := jwk.FromRaw(&privateKey.PublicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = pubJWK.Set(jwk.KeyIDKey, keyID)
|
||||
_ = pubJWK.Set(jwk.AlgorithmKey, "RS256")
|
||||
_ = pubJWK.Set(jwk.KeyUsageKey, "sig")
|
||||
|
||||
jwks := jwk.NewSet()
|
||||
_ = jwks.AddKey(pubJWK)
|
||||
|
||||
return jwks, nil
|
||||
}
|
||||
|
||||
// GetJWKS 获取JWKS(简化版本)
|
||||
func GetJWKS() jwk.Set {
|
||||
return simpleJWKSSet
|
||||
}
|
||||
|
||||
// HandleTokenRequest 简化的令牌处理(临时实现)
|
||||
func HandleTokenRequest(c *gin.Context) {
|
||||
c.JSON(501, map[string]string{
|
||||
"error": "not_implemented",
|
||||
"error_description": "OAuth2 token endpoint not fully implemented yet",
|
||||
})
|
||||
}
|
||||
|
||||
// HandleAuthorizeRequest 简化的授权处理(临时实现)
|
||||
func HandleAuthorizeRequest(c *gin.Context) {
|
||||
c.JSON(501, map[string]string{
|
||||
"error": "not_implemented",
|
||||
"error_description": "OAuth2 authorize endpoint not fully implemented yet",
|
||||
})
|
||||
}
|
||||
318
web/src/components/modals/oauth2/CreateOAuth2ClientModal.jsx
Normal file
318
web/src/components/modals/oauth2/CreateOAuth2ClientModal.jsx
Normal file
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
TextArea,
|
||||
Switch,
|
||||
Space,
|
||||
Typography,
|
||||
Divider,
|
||||
Tag,
|
||||
Button,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess, showInfo } from '../../../helpers';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [redirectUris, setRedirectUris] = useState(['']);
|
||||
const [clientType, setClientType] = useState('confidential');
|
||||
const [grantTypes, setGrantTypes] = useState(['client_credentials']);
|
||||
|
||||
// 处理提交
|
||||
const handleSubmit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 过滤空的重定向URI
|
||||
const validRedirectUris = redirectUris.filter(uri => uri.trim());
|
||||
|
||||
const payload = {
|
||||
...values,
|
||||
client_type: clientType,
|
||||
grant_types: grantTypes,
|
||||
redirect_uris: validRedirectUris,
|
||||
};
|
||||
|
||||
const res = await API.post('/api/oauth_clients/', payload);
|
||||
const { success, message, client_id, client_secret } = res.data;
|
||||
|
||||
if (success) {
|
||||
showSuccess('OAuth2客户端创建成功');
|
||||
|
||||
// 显示客户端信息
|
||||
Modal.info({
|
||||
title: '客户端创建成功',
|
||||
content: (
|
||||
<div>
|
||||
<Paragraph>请妥善保存以下信息:</Paragraph>
|
||||
<div style={{ background: '#f8f9fa', padding: '16px', borderRadius: '6px' }}>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<Text strong>客户端ID:</Text>
|
||||
<br />
|
||||
<Text code copyable style={{ fontFamily: 'monospace' }}>
|
||||
{client_id}
|
||||
</Text>
|
||||
</div>
|
||||
{client_secret && (
|
||||
<div>
|
||||
<Text strong>客户端密钥(仅此一次显示):</Text>
|
||||
<br />
|
||||
<Text code copyable style={{ fontFamily: 'monospace' }}>
|
||||
{client_secret}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Paragraph type="warning" style={{ marginTop: '12px' }}>
|
||||
{client_secret
|
||||
? '客户端密钥仅显示一次,请立即复制保存。'
|
||||
: '公开客户端无需密钥。'
|
||||
}
|
||||
</Paragraph>
|
||||
</div>
|
||||
),
|
||||
width: 600,
|
||||
onOk: () => {
|
||||
resetForm();
|
||||
onSuccess();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('创建OAuth2客户端失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
if (formApi) {
|
||||
formApi.reset();
|
||||
}
|
||||
setClientType('confidential');
|
||||
setGrantTypes(['client_credentials']);
|
||||
setRedirectUris(['']);
|
||||
};
|
||||
|
||||
// 处理取消
|
||||
const handleCancel = () => {
|
||||
resetForm();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
// 添加重定向URI
|
||||
const addRedirectUri = () => {
|
||||
setRedirectUris([...redirectUris, '']);
|
||||
};
|
||||
|
||||
// 删除重定向URI
|
||||
const removeRedirectUri = (index) => {
|
||||
setRedirectUris(redirectUris.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 更新重定向URI
|
||||
const updateRedirectUri = (index, value) => {
|
||||
const newUris = [...redirectUris];
|
||||
newUris[index] = value;
|
||||
setRedirectUris(newUris);
|
||||
};
|
||||
|
||||
// 授权类型变化处理
|
||||
const handleGrantTypesChange = (values) => {
|
||||
setGrantTypes(values);
|
||||
// 如果包含authorization_code但没有重定向URI,则添加一个
|
||||
if (values.includes('authorization_code') && redirectUris.length === 1 && !redirectUris[0]) {
|
||||
setRedirectUris(['']);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="创建OAuth2客户端"
|
||||
visible={visible}
|
||||
onCancel={handleCancel}
|
||||
onOk={() => formApi?.submit()}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
confirmLoading={loading}
|
||||
width={600}
|
||||
style={{ top: 50 }}
|
||||
>
|
||||
<Form
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
onSubmit={handleSubmit}
|
||||
labelPosition="top"
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<Form.Input
|
||||
field="name"
|
||||
label="客户端名称"
|
||||
placeholder="输入客户端名称"
|
||||
rules={[{ required: true, message: '请输入客户端名称' }]}
|
||||
/>
|
||||
|
||||
<Form.TextArea
|
||||
field="description"
|
||||
label="描述"
|
||||
placeholder="输入客户端描述"
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
{/* 客户端类型 */}
|
||||
<div>
|
||||
<Text strong>客户端类型</Text>
|
||||
<Paragraph type="tertiary" size="small" style={{ marginTop: 4, marginBottom: 8 }}>
|
||||
选择适合您应用程序的客户端类型。
|
||||
</Paragraph>
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: 16 }}>
|
||||
<div
|
||||
onClick={() => setClientType('confidential')}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
border: `2px solid ${clientType === 'confidential' ? '#3370ff' : '#e4e6e9'}`,
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
background: clientType === 'confidential' ? '#f0f5ff' : '#fff'
|
||||
}}
|
||||
>
|
||||
<Text strong>机密客户端(Confidential)</Text>
|
||||
<Paragraph type="tertiary" size="small" style={{ margin: '4px 0 0 0' }}>
|
||||
用于服务器端应用,可以安全地存储客户端密钥
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setClientType('public')}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
border: `2px solid ${clientType === 'public' ? '#3370ff' : '#e4e6e9'}`,
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
background: clientType === 'public' ? '#f0f5ff' : '#fff'
|
||||
}}
|
||||
>
|
||||
<Text strong>公开客户端(Public)</Text>
|
||||
<Paragraph type="tertiary" size="small" style={{ margin: '4px 0 0 0' }}>
|
||||
用于移动应用或单页应用,无法安全存储密钥
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 授权类型 */}
|
||||
<Form.Select
|
||||
field="grant_types"
|
||||
label="允许的授权类型"
|
||||
multiple
|
||||
value={grantTypes}
|
||||
onChange={handleGrantTypesChange}
|
||||
rules={[{ required: true, message: '请选择至少一种授权类型' }]}
|
||||
>
|
||||
<Option value="client_credentials">Client Credentials(客户端凭证)</Option>
|
||||
<Option value="authorization_code">Authorization Code(授权码)</Option>
|
||||
<Option value="refresh_token">Refresh Token(刷新令牌)</Option>
|
||||
</Form.Select>
|
||||
|
||||
{/* Scope */}
|
||||
<Form.Select
|
||||
field="scopes"
|
||||
label="允许的权限范围(Scope)"
|
||||
multiple
|
||||
defaultValue={['api:read']}
|
||||
rules={[{ required: true, message: '请选择至少一个权限范围' }]}
|
||||
>
|
||||
<Option value="api:read">api:read(读取API)</Option>
|
||||
<Option value="api:write">api:write(写入API)</Option>
|
||||
<Option value="admin">admin(管理员权限)</Option>
|
||||
</Form.Select>
|
||||
|
||||
{/* PKCE设置 */}
|
||||
<Form.Switch
|
||||
field="require_pkce"
|
||||
label="强制PKCE验证"
|
||||
defaultChecked={true}
|
||||
/>
|
||||
<Paragraph type="tertiary" size="small" style={{ marginTop: -8, marginBottom: 16 }}>
|
||||
PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。
|
||||
</Paragraph>
|
||||
|
||||
{/* 重定向URI */}
|
||||
{grantTypes.includes('authorization_code') && (
|
||||
<>
|
||||
<Divider>重定向URI配置</Divider>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text strong>重定向URI</Text>
|
||||
<Paragraph type="tertiary" size="small">
|
||||
用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP)。
|
||||
</Paragraph>
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{redirectUris.map((uri, index) => (
|
||||
<Space key={index} style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="https://your-app.com/callback"
|
||||
value={uri}
|
||||
onChange={(value) => updateRedirectUri(index, value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{redirectUris.length > 1 && (
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="danger"
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
onClick={() => removeRedirectUri(index)}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<IconPlus />}
|
||||
onClick={addRedirectUri}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
添加重定向URI
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateOAuth2ClientModal;
|
||||
306
web/src/components/modals/oauth2/EditOAuth2ClientModal.jsx
Normal file
306
web/src/components/modals/oauth2/EditOAuth2ClientModal.jsx
Normal file
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
TextArea,
|
||||
Switch,
|
||||
Space,
|
||||
Typography,
|
||||
Divider,
|
||||
Button,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [redirectUris, setRedirectUris] = useState(['']);
|
||||
const [grantTypes, setGrantTypes] = useState(['client_credentials']);
|
||||
|
||||
// 初始化表单数据
|
||||
useEffect(() => {
|
||||
if (client && visible) {
|
||||
// 解析授权类型
|
||||
let parsedGrantTypes = [];
|
||||
if (typeof client.grant_types === 'string') {
|
||||
parsedGrantTypes = client.grant_types.split(',');
|
||||
} else if (Array.isArray(client.grant_types)) {
|
||||
parsedGrantTypes = client.grant_types;
|
||||
}
|
||||
|
||||
// 解析Scope
|
||||
let parsedScopes = [];
|
||||
if (typeof client.scopes === 'string') {
|
||||
parsedScopes = client.scopes.split(',');
|
||||
} else if (Array.isArray(client.scopes)) {
|
||||
parsedScopes = client.scopes;
|
||||
}
|
||||
|
||||
// 解析重定向URI
|
||||
let parsedRedirectUris = [''];
|
||||
if (client.redirect_uris) {
|
||||
try {
|
||||
const parsed = typeof client.redirect_uris === 'string'
|
||||
? JSON.parse(client.redirect_uris)
|
||||
: client.redirect_uris;
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
parsedRedirectUris = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse redirect URIs:', e);
|
||||
}
|
||||
}
|
||||
|
||||
setGrantTypes(parsedGrantTypes);
|
||||
setRedirectUris(parsedRedirectUris);
|
||||
|
||||
// 设置表单值
|
||||
const formValues = {
|
||||
id: client.id,
|
||||
name: client.name,
|
||||
description: client.description,
|
||||
client_type: client.client_type,
|
||||
grant_types: parsedGrantTypes,
|
||||
scopes: parsedScopes,
|
||||
require_pkce: client.require_pkce,
|
||||
status: client.status,
|
||||
};
|
||||
if (formApi) {
|
||||
formApi.setValues(formValues);
|
||||
}
|
||||
}
|
||||
}, [client, visible, formApi]);
|
||||
|
||||
// 处理提交
|
||||
const handleSubmit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 过滤空的重定向URI
|
||||
const validRedirectUris = redirectUris.filter(uri => uri.trim());
|
||||
|
||||
const payload = {
|
||||
...values,
|
||||
grant_types: grantTypes,
|
||||
redirect_uris: validRedirectUris,
|
||||
};
|
||||
|
||||
const res = await API.put('/api/oauth_clients/', payload);
|
||||
const { success, message } = res.data;
|
||||
|
||||
if (success) {
|
||||
showSuccess('OAuth2客户端更新成功');
|
||||
onSuccess();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('更新OAuth2客户端失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加重定向URI
|
||||
const addRedirectUri = () => {
|
||||
setRedirectUris([...redirectUris, '']);
|
||||
};
|
||||
|
||||
// 删除重定向URI
|
||||
const removeRedirectUri = (index) => {
|
||||
setRedirectUris(redirectUris.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 更新重定向URI
|
||||
const updateRedirectUri = (index, value) => {
|
||||
const newUris = [...redirectUris];
|
||||
newUris[index] = value;
|
||||
setRedirectUris(newUris);
|
||||
};
|
||||
|
||||
// 授权类型变化处理
|
||||
const handleGrantTypesChange = (values) => {
|
||||
setGrantTypes(values);
|
||||
// 如果包含authorization_code但没有重定向URI,则添加一个
|
||||
if (values.includes('authorization_code') && redirectUris.length === 1 && !redirectUris[0]) {
|
||||
setRedirectUris(['']);
|
||||
}
|
||||
};
|
||||
|
||||
if (!client) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`编辑OAuth2客户端 - ${client.name}`}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={() => formApi?.submit()}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={loading}
|
||||
width={600}
|
||||
style={{ top: 50 }}
|
||||
>
|
||||
<Form
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
onSubmit={handleSubmit}
|
||||
labelPosition="top"
|
||||
>
|
||||
{/* 客户端ID(只读) */}
|
||||
<Form.Input
|
||||
field="id"
|
||||
label="客户端ID"
|
||||
disabled
|
||||
style={{ backgroundColor: '#f8f9fa' }}
|
||||
/>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<Form.Input
|
||||
field="name"
|
||||
label="客户端名称"
|
||||
placeholder="输入客户端名称"
|
||||
rules={[{ required: true, message: '请输入客户端名称' }]}
|
||||
/>
|
||||
|
||||
<Form.TextArea
|
||||
field="description"
|
||||
label="描述"
|
||||
placeholder="输入客户端描述"
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
{/* 客户端类型(只读) */}
|
||||
<Form.Select
|
||||
field="client_type"
|
||||
label="客户端类型"
|
||||
disabled
|
||||
style={{ backgroundColor: '#f8f9fa' }}
|
||||
>
|
||||
<Option value="confidential">机密客户端(Confidential)</Option>
|
||||
<Option value="public">公开客户端(Public)</Option>
|
||||
</Form.Select>
|
||||
|
||||
<Paragraph type="tertiary" size="small" style={{ marginTop: -8, marginBottom: 16 }}>
|
||||
客户端类型创建后不可更改。
|
||||
</Paragraph>
|
||||
|
||||
{/* 授权类型 */}
|
||||
<Form.Select
|
||||
field="grant_types"
|
||||
label="允许的授权类型"
|
||||
multiple
|
||||
value={grantTypes}
|
||||
onChange={handleGrantTypesChange}
|
||||
rules={[{ required: true, message: '请选择至少一种授权类型' }]}
|
||||
>
|
||||
<Option value="client_credentials">Client Credentials(客户端凭证)</Option>
|
||||
<Option value="authorization_code">Authorization Code(授权码)</Option>
|
||||
<Option value="refresh_token">Refresh Token(刷新令牌)</Option>
|
||||
</Form.Select>
|
||||
|
||||
{/* Scope */}
|
||||
<Form.Select
|
||||
field="scopes"
|
||||
label="允许的权限范围(Scope)"
|
||||
multiple
|
||||
rules={[{ required: true, message: '请选择至少一个权限范围' }]}
|
||||
>
|
||||
<Option value="api:read">api:read(读取API)</Option>
|
||||
<Option value="api:write">api:write(写入API)</Option>
|
||||
<Option value="admin">admin(管理员权限)</Option>
|
||||
</Form.Select>
|
||||
|
||||
{/* PKCE设置 */}
|
||||
<Form.Switch
|
||||
field="require_pkce"
|
||||
label="强制PKCE验证"
|
||||
/>
|
||||
<Paragraph type="tertiary" size="small" style={{ marginTop: -8, marginBottom: 16 }}>
|
||||
PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。
|
||||
</Paragraph>
|
||||
|
||||
{/* 状态 */}
|
||||
<Form.Select
|
||||
field="status"
|
||||
label="状态"
|
||||
rules={[{ required: true, message: '请选择状态' }]}
|
||||
>
|
||||
<Option value={1}>启用</Option>
|
||||
<Option value={2}>禁用</Option>
|
||||
</Form.Select>
|
||||
|
||||
{/* 重定向URI */}
|
||||
{grantTypes.includes('authorization_code') && (
|
||||
<>
|
||||
<Divider>重定向URI配置</Divider>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text strong>重定向URI</Text>
|
||||
<Paragraph type="tertiary" size="small">
|
||||
用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP)。
|
||||
</Paragraph>
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{redirectUris.map((uri, index) => (
|
||||
<Space key={index} style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="https://your-app.com/callback"
|
||||
value={uri}
|
||||
onChange={(value) => updateRedirectUri(index, value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{redirectUris.length > 1 && (
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="danger"
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
onClick={() => removeRedirectUri(index)}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<IconPlus />}
|
||||
onClick={addRedirectUri}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
添加重定向URI
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditOAuth2ClientModal;
|
||||
101
web/src/components/settings/OAuth2Setting.jsx
Normal file
101
web/src/components/settings/OAuth2Setting.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||
import { API, showError, toBoolean } from '../../helpers';
|
||||
import OAuth2ServerSettings from '../../pages/Setting/OAuth2/OAuth2ServerSettings';
|
||||
import OAuth2ClientSettings from '../../pages/Setting/OAuth2/OAuth2ClientSettings';
|
||||
|
||||
const OAuth2Setting = () => {
|
||||
const [inputs, setInputs] = useState({
|
||||
'oauth2.enabled': false,
|
||||
'oauth2.issuer': '',
|
||||
'oauth2.access_token_ttl': 10,
|
||||
'oauth2.refresh_token_ttl': 720,
|
||||
'oauth2.jwt_signing_algorithm': 'RS256',
|
||||
'oauth2.jwt_key_id': 'oauth2-key-1',
|
||||
'oauth2.jwt_private_key_file': '',
|
||||
'oauth2.allowed_grant_types': ['client_credentials', 'authorization_code'],
|
||||
'oauth2.require_pkce': true,
|
||||
'oauth2.auto_create_user': false,
|
||||
'oauth2.default_user_role': 1,
|
||||
'oauth2.default_user_group': 'default',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const getOptions = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/option/');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
data.forEach((item) => {
|
||||
if (Object.keys(inputs).includes(item.key)) {
|
||||
if (item.key === 'oauth2.allowed_grant_types') {
|
||||
try {
|
||||
newInputs[item.key] = JSON.parse(item.value || '["client_credentials","authorization_code"]');
|
||||
} catch {
|
||||
newInputs[item.key] = ['client_credentials', 'authorization_code'];
|
||||
}
|
||||
} else if (typeof inputs[item.key] === 'boolean') {
|
||||
newInputs[item.key] = toBoolean(item.value);
|
||||
} else if (typeof inputs[item.key] === 'number') {
|
||||
newInputs[item.key] = parseInt(item.value) || inputs[item.key];
|
||||
} else {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
setInputs({...inputs, ...newInputs});
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('获取OAuth2设置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
getOptions();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getOptions();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
marginTop: '10px',
|
||||
}}
|
||||
>
|
||||
<OAuth2ServerSettings options={inputs} refresh={refresh} />
|
||||
<OAuth2ClientSettings />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuth2Setting;
|
||||
420
web/src/pages/Setting/OAuth2/OAuth2ClientSettings.jsx
Normal file
420
web/src/pages/Setting/OAuth2/OAuth2ClientSettings.jsx
Normal file
@@ -0,0 +1,420 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <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,
|
||||
Modal,
|
||||
Form,
|
||||
Banner,
|
||||
Row,
|
||||
Col
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconSearch, IconPlus } from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess, showInfo } from '../../../helpers';
|
||||
import CreateOAuth2ClientModal from '../../../components/modals/oauth2/CreateOAuth2ClientModal';
|
||||
import EditOAuth2ClientModal from '../../../components/modals/oauth2/EditOAuth2ClientModal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
export default function OAuth2ClientSettings() {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [clients, setClients] = useState([]);
|
||||
const [filteredClients, setFilteredClients] = useState([]);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingClient, setEditingClient] = useState(null);
|
||||
const [showSecretModal, setShowSecretModal] = useState(false);
|
||||
const [currentSecret, setCurrentSecret] = useState('');
|
||||
|
||||
// 加载客户端列表
|
||||
const loadClients = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/oauth_clients/');
|
||||
if (res.data.success) {
|
||||
setClients(res.data.data || []);
|
||||
setFilteredClients(res.data.data || []);
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('加载OAuth2客户端失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索过滤
|
||||
const handleSearch = (value) => {
|
||||
setSearchKeyword(value);
|
||||
if (!value) {
|
||||
setFilteredClients(clients);
|
||||
} else {
|
||||
const filtered = clients.filter(client =>
|
||||
client.name?.toLowerCase().includes(value.toLowerCase()) ||
|
||||
client.id?.toLowerCase().includes(value.toLowerCase()) ||
|
||||
client.description?.toLowerCase().includes(value.toLowerCase())
|
||||
);
|
||||
setFilteredClients(filtered);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除客户端
|
||||
const handleDelete = async (client) => {
|
||||
try {
|
||||
const res = await API.delete(`/api/oauth_clients/${client.id}`);
|
||||
if (res.data.success) {
|
||||
showSuccess('删除成功');
|
||||
loadClients();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 重新生成密钥
|
||||
const handleRegenerateSecret = async (client) => {
|
||||
try {
|
||||
const res = await API.post(`/api/oauth_clients/${client.id}/regenerate_secret`);
|
||||
if (res.data.success) {
|
||||
setCurrentSecret(res.data.client_secret);
|
||||
setShowSecretModal(true);
|
||||
loadClients();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('重新生成密钥失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: '客户端名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
<div>
|
||||
<Text strong>{text}</Text>
|
||||
<br />
|
||||
<Text type="tertiary" size="small">{record.id}</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'client_type',
|
||||
key: 'client_type',
|
||||
render: (text) => (
|
||||
<Tag color={text === 'confidential' ? 'blue' : 'green'}>
|
||||
{text === 'confidential' ? '机密客户端' : '公开客户端'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '授权类型',
|
||||
dataIndex: 'grant_types',
|
||||
key: 'grant_types',
|
||||
render: (grantTypes) => {
|
||||
const types = typeof grantTypes === 'string' ? grantTypes.split(',') : (grantTypes || []);
|
||||
return (
|
||||
<div>
|
||||
{types.map(type => (
|
||||
<Tag key={type} size="small" style={{ margin: '2px' }}>
|
||||
{type === 'client_credentials' ? '客户端凭证' :
|
||||
type === 'authorization_code' ? '授权码' :
|
||||
type === 'refresh_token' ? '刷新令牌' : type}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status) => (
|
||||
<Tag color={status === 1 ? 'green' : 'red'}>
|
||||
{status === 1 ? '启用' : '禁用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_time',
|
||||
key: 'created_time',
|
||||
render: (time) => new Date(time * 1000).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditingClient(record);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{record.client_type === 'confidential' && (
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="secondary"
|
||||
size="small"
|
||||
onClick={() => handleRegenerateSecret(record)}
|
||||
>
|
||||
重新生成密钥
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确定删除这个OAuth2客户端吗?"
|
||||
content="删除后无法恢复,相关的API访问将失效。"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="danger"
|
||||
size="small"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadClients();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card style={{ marginTop: 10 }}>
|
||||
<Form.Section text={'OAuth2 客户端管理'}>
|
||||
<Banner
|
||||
type="info"
|
||||
description="管理OAuth2客户端应用程序,每个客户端代表一个可以访问API的应用程序。机密客户端用于服务器端应用,公开客户端用于移动应用或单页应用。"
|
||||
style={{ marginBottom: 15 }}
|
||||
/>
|
||||
|
||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={24} md={12} lg={8} xl={8}>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder="搜索客户端名称、ID或描述"
|
||||
value={searchKeyword}
|
||||
onChange={handleSearch}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={16} xl={16} style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button onClick={loadClients}>刷新</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconPlus />}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
创建OAuth2客户端
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredClients}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`,
|
||||
pageSize: 10,
|
||||
}}
|
||||
empty={
|
||||
<div style={{ textAlign: 'center', padding: '50px 0' }}>
|
||||
<Text type="tertiary">暂无OAuth2客户端</Text>
|
||||
<br />
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconPlus />}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
style={{ marginTop: 10 }}
|
||||
>
|
||||
创建第一个客户端
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 快速操作 */}
|
||||
<div style={{ marginTop: 20, marginBottom: 10 }}>
|
||||
<Text strong>快速操作</Text>
|
||||
</div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Space wrap>
|
||||
<Button
|
||||
type="tertiary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await API.get('/api/oauth/jwks');
|
||||
Modal.info({
|
||||
title: 'JWKS信息',
|
||||
content: (
|
||||
<div>
|
||||
<Text>JSON Web Key Set:</Text>
|
||||
<pre style={{
|
||||
background: '#f8f9fa',
|
||||
padding: '12px',
|
||||
borderRadius: '4px',
|
||||
marginTop: '8px',
|
||||
fontSize: '12px',
|
||||
maxHeight: '300px',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
{JSON.stringify(res.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
),
|
||||
width: 600
|
||||
});
|
||||
} catch (error) {
|
||||
showError('获取JWKS失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
查看JWKS
|
||||
</Button>
|
||||
<Button
|
||||
type="tertiary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await API.get('/api/oauth/server-info');
|
||||
Modal.info({
|
||||
title: 'OAuth2服务器信息',
|
||||
content: (
|
||||
<div>
|
||||
<Text>授权服务器配置:</Text>
|
||||
<pre style={{
|
||||
background: '#f8f9fa',
|
||||
padding: '12px',
|
||||
borderRadius: '4px',
|
||||
marginTop: '8px',
|
||||
fontSize: '12px',
|
||||
maxHeight: '300px',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
{JSON.stringify(res.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
),
|
||||
width: 600
|
||||
});
|
||||
} catch (error) {
|
||||
showError('获取服务器信息失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
查看服务器信息
|
||||
</Button>
|
||||
<Button
|
||||
type="tertiary"
|
||||
onClick={() => showInfo('OAuth2集成文档功能开发中,请参考相关API文档')}
|
||||
>
|
||||
集成文档
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
{/* 创建客户端模态框 */}
|
||||
<CreateOAuth2ClientModal
|
||||
visible={showCreateModal}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowCreateModal(false);
|
||||
loadClients();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 编辑客户端模态框 */}
|
||||
<EditOAuth2ClientModal
|
||||
visible={showEditModal}
|
||||
client={editingClient}
|
||||
onCancel={() => {
|
||||
setShowEditModal(false);
|
||||
setEditingClient(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setShowEditModal(false);
|
||||
setEditingClient(null);
|
||||
loadClients();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 密钥显示模态框 */}
|
||||
<Modal
|
||||
title="客户端密钥已重新生成"
|
||||
visible={showSecretModal}
|
||||
onCancel={() => setShowSecretModal(false)}
|
||||
onOk={() => setShowSecretModal(false)}
|
||||
cancelText=""
|
||||
okText="我已复制保存"
|
||||
width={600}
|
||||
>
|
||||
<div>
|
||||
<Text>新的客户端密钥如下,请立即复制保存。关闭此窗口后将无法再次查看。</Text>
|
||||
<div style={{
|
||||
background: '#f8f9fa',
|
||||
padding: '16px',
|
||||
borderRadius: '6px',
|
||||
marginTop: '16px',
|
||||
fontFamily: 'monospace',
|
||||
wordBreak: 'break-all'
|
||||
}}>
|
||||
<Text code copyable>{currentSecret}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
351
web/src/pages/Setting/OAuth2/OAuth2ServerSettings.jsx
Normal file
351
web/src/pages/Setting/OAuth2/OAuth2ServerSettings.jsx
Normal file
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <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 } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function OAuth2ServerSettings(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
'oauth2.enabled': false,
|
||||
'oauth2.issuer': '',
|
||||
'oauth2.access_token_ttl': 10,
|
||||
'oauth2.refresh_token_ttl': 720,
|
||||
'oauth2.jwt_signing_algorithm': 'RS256',
|
||||
'oauth2.jwt_key_id': 'oauth2-key-1',
|
||||
'oauth2.jwt_private_key_file': '',
|
||||
'oauth2.allowed_grant_types': ['client_credentials', 'authorization_code'],
|
||||
'oauth2.require_pkce': true,
|
||||
'oauth2.auto_create_user': false,
|
||||
'oauth2.default_user_role': 1,
|
||||
'oauth2.default_user_group': 'default',
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
|
||||
function handleFieldChange(fieldName) {
|
||||
return (value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [fieldName]: value }));
|
||||
};
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
let value = '';
|
||||
if (typeof inputs[item.key] === 'boolean') {
|
||||
value = String(inputs[item.key]);
|
||||
} else if (Array.isArray(inputs[item.key])) {
|
||||
value = JSON.stringify(inputs[item.key]);
|
||||
} else {
|
||||
value = inputs[item.key];
|
||||
}
|
||||
return API.put('/api/option/', {
|
||||
key: item.key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (requestQueue.length === 1) {
|
||||
if (res.includes(undefined)) return;
|
||||
} else if (requestQueue.length > 1) {
|
||||
if (res.includes(undefined))
|
||||
return showError(t('部分保存失败,请重试'));
|
||||
}
|
||||
showSuccess(t('保存成功'));
|
||||
if (props && props.refresh) {
|
||||
props.refresh();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
// 测试OAuth2连接
|
||||
const testOAuth2 = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/oauth/server-info');
|
||||
if (res.data.success) {
|
||||
showSuccess('OAuth2服务器运行正常');
|
||||
} else {
|
||||
showError('OAuth2服务器测试失败: ' + res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('OAuth2服务器连接测试失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props && props.options) {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
if (key === 'oauth2.allowed_grant_types') {
|
||||
try {
|
||||
currentInputs[key] = JSON.parse(props.options[key] || '["client_credentials","authorization_code"]');
|
||||
} catch {
|
||||
currentInputs[key] = ['client_credentials', 'authorization_code'];
|
||||
}
|
||||
} else if (typeof inputs[key] === 'boolean') {
|
||||
currentInputs[key] = props.options[key] === 'true';
|
||||
} else if (typeof inputs[key] === 'number') {
|
||||
currentInputs[key] = parseInt(props.options[key]) || inputs[key];
|
||||
} else {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
setInputs({...inputs, ...currentInputs});
|
||||
setInputsRow(structuredClone({...inputs, ...currentInputs}));
|
||||
if (refForm.current) {
|
||||
refForm.current.setValues({...inputs, ...currentInputs});
|
||||
}
|
||||
}
|
||||
}, [props]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
>
|
||||
<Form.Section text={'OAuth2 服务器设置'}>
|
||||
<Banner
|
||||
type="info"
|
||||
description={
|
||||
<div>
|
||||
<p>• OAuth2服务器提供标准的API认证和授权功能</p>
|
||||
<p>• 支持Client Credentials、Authorization Code + PKCE等标准流程</p>
|
||||
<p>• 更改配置后需要重启服务才能生效</p>
|
||||
<p>• 生产环境务必配置HTTPS和安全的JWT签名密钥</p>
|
||||
</div>
|
||||
}
|
||||
style={{ marginBottom: 15 }}
|
||||
/>
|
||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field='oauth2.enabled'
|
||||
label={t('启用OAuth2服务器')}
|
||||
checkedText='开'
|
||||
uncheckedText='关'
|
||||
value={inputs['oauth2.enabled']}
|
||||
onChange={handleFieldChange('oauth2.enabled')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Form.Input
|
||||
field='oauth2.issuer'
|
||||
label={t('签发者标识(Issuer)')}
|
||||
placeholder="https://your-domain.com"
|
||||
extraText="OAuth2令牌的签发者,通常是您的域名"
|
||||
value={inputs['oauth2.issuer']}
|
||||
onChange={handleFieldChange('oauth2.issuer')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={onSubmit} loading={loading}>{t('更新服务器设置')}</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginTop: 10 }}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
>
|
||||
<Form.Section text={'令牌配置'}>
|
||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='oauth2.access_token_ttl'
|
||||
label={t('访问令牌有效期')}
|
||||
suffix="分钟"
|
||||
min={1}
|
||||
max={1440}
|
||||
value={inputs['oauth2.access_token_ttl']}
|
||||
onChange={handleFieldChange('oauth2.access_token_ttl')}
|
||||
extraText="访问令牌的有效时间,建议较短(10-60分钟)"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='oauth2.refresh_token_ttl'
|
||||
label={t('刷新令牌有效期')}
|
||||
suffix="小时"
|
||||
min={1}
|
||||
max={8760}
|
||||
value={inputs['oauth2.refresh_token_ttl']}
|
||||
onChange={handleFieldChange('oauth2.refresh_token_ttl')}
|
||||
extraText="刷新令牌的有效时间,建议较长(12-720小时)"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<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="用于标识JWT签名密钥,支持密钥轮换"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Select
|
||||
field='oauth2.jwt_signing_algorithm'
|
||||
label={t('JWT签名算法')}
|
||||
value={inputs['oauth2.jwt_signing_algorithm']}
|
||||
onChange={handleFieldChange('oauth2.jwt_signing_algorithm')}
|
||||
extraText="JWT令牌的签名算法,推荐使用RS256"
|
||||
>
|
||||
<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} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Input
|
||||
field='oauth2.jwt_private_key_file'
|
||||
label={t('JWT私钥文件路径')}
|
||||
placeholder="/path/to/oauth2-private-key.pem"
|
||||
value={inputs['oauth2.jwt_private_key_file']}
|
||||
onChange={handleFieldChange('oauth2.jwt_private_key_file')}
|
||||
extraText="RSA私钥文件路径,留空将使用内存生成的密钥"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={onSubmit} loading={loading}>{t('更新令牌配置')}</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginTop: 10 }}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
>
|
||||
<Form.Section text={'授权配置'}>
|
||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Select
|
||||
field='oauth2.allowed_grant_types'
|
||||
label={t('允许的授权类型')}
|
||||
multiple
|
||||
value={inputs['oauth2.allowed_grant_types']}
|
||||
onChange={handleFieldChange('oauth2.allowed_grant_types')}
|
||||
extraText="选择允许的OAuth2授权流程"
|
||||
>
|
||||
<Form.Select.Option value="client_credentials">Client Credentials(客户端凭证)</Form.Select.Option>
|
||||
<Form.Select.Option value="authorization_code">Authorization Code(授权码)</Form.Select.Option>
|
||||
<Form.Select.Option value="refresh_token">Refresh Token(刷新令牌)</Form.Select.Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Switch
|
||||
field='oauth2.require_pkce'
|
||||
label={t('强制PKCE验证')}
|
||||
checkedText='开'
|
||||
uncheckedText='关'
|
||||
value={inputs['oauth2.require_pkce']}
|
||||
onChange={handleFieldChange('oauth2.require_pkce')}
|
||||
extraText="为授权码流程强制启用PKCE,提高安全性"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={onSubmit} loading={loading}>{t('更新授权配置')}</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginTop: 10 }}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
>
|
||||
<Form.Section text={'用户配置'}>
|
||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field='oauth2.auto_create_user'
|
||||
label={t('自动创建用户')}
|
||||
checkedText='开'
|
||||
uncheckedText='关'
|
||||
value={inputs['oauth2.auto_create_user']}
|
||||
onChange={handleFieldChange('oauth2.auto_create_user')}
|
||||
extraText="首次OAuth2登录时自动创建用户账户"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Select
|
||||
field='oauth2.default_user_role'
|
||||
label={t('默认用户角色')}
|
||||
value={inputs['oauth2.default_user_role']}
|
||||
onChange={handleFieldChange('oauth2.default_user_role')}
|
||||
extraText="自动创建用户时的默认角色"
|
||||
>
|
||||
<Form.Select.Option value={1}>普通用户</Form.Select.Option>
|
||||
<Form.Select.Option value={10}>管理员</Form.Select.Option>
|
||||
<Form.Select.Option value={100}>超级管理员</Form.Select.Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='oauth2.default_user_group'
|
||||
label={t('默认用户分组')}
|
||||
placeholder="default"
|
||||
value={inputs['oauth2.default_user_group']}
|
||||
onChange={handleFieldChange('oauth2.default_user_group')}
|
||||
extraText="自动创建用户时的默认分组"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={onSubmit} loading={loading}>{t('更新用户配置')}</Button>
|
||||
<Button
|
||||
type="secondary"
|
||||
onClick={testOAuth2}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
{t('测试连接')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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