mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-19 11:48:38 +00:00
feat: oauth2
This commit is contained in:
@@ -3,10 +3,16 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"one-api/model"
|
||||||
"one-api/setting/system_setting"
|
"one-api/setting/system_setting"
|
||||||
"one-api/src/oauth"
|
"one-api/src/oauth"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
"one-api/middleware"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetJWKS 获取JWKS公钥集
|
// GetJWKS 获取JWKS公钥集
|
||||||
@@ -19,6 +25,9 @@ func GetJWKS(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lazy init if needed
|
||||||
|
_ = oauth.EnsureInitialized()
|
||||||
|
|
||||||
jwks := oauth.GetJWKS()
|
jwks := oauth.GetJWKS()
|
||||||
if jwks == nil {
|
if jwks == nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
@@ -70,7 +79,7 @@ func OAuthTokenEndpoint(c *gin.Context) {
|
|||||||
|
|
||||||
// 只允许application/x-www-form-urlencoded内容类型
|
// 只允许application/x-www-form-urlencoded内容类型
|
||||||
contentType := c.GetHeader("Content-Type")
|
contentType := c.GetHeader("Content-Type")
|
||||||
if contentType != "application/x-www-form-urlencoded" {
|
if contentType == "" || !strings.Contains(strings.ToLower(contentType), "application/x-www-form-urlencoded") {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
"error": "invalid_request",
|
"error": "invalid_request",
|
||||||
"error_description": "Content-Type must be application/x-www-form-urlencoded",
|
"error_description": "Content-Type must be application/x-www-form-urlencoded",
|
||||||
@@ -78,7 +87,11 @@ func OAuthTokenEndpoint(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 委托给OAuth2服务器处理
|
// lazy init
|
||||||
|
if err := oauth.EnsureInitialized(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error", "error_description": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
oauth.HandleTokenRequest(c)
|
oauth.HandleTokenRequest(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +106,10 @@ func OAuthAuthorizeEndpoint(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 委托给OAuth2服务器处理
|
if err := oauth.EnsureInitialized(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error", "error_description": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
oauth.HandleAuthorizeRequest(c)
|
oauth.HandleAuthorizeRequest(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,20 +124,68 @@ func OAuthServerInfo(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 返回OAuth2服务器的基本信息(类似OpenID Connect Discovery)
|
// 返回OAuth2服务器的基本信息(类似OpenID Connect Discovery)
|
||||||
|
issuer := settings.Issuer
|
||||||
|
if issuer == "" {
|
||||||
|
scheme := "https"
|
||||||
|
if c.Request.TLS == nil {
|
||||||
|
if hdr := c.Request.Header.Get("X-Forwarded-Proto"); hdr != "" {
|
||||||
|
scheme = hdr
|
||||||
|
} else {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issuer = scheme + "://" + c.Request.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
base := issuer + "/api"
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"issuer": settings.Issuer,
|
"issuer": issuer,
|
||||||
"authorization_endpoint": settings.Issuer + "/oauth/authorize",
|
"authorization_endpoint": base + "/oauth/authorize",
|
||||||
"token_endpoint": settings.Issuer + "/oauth/token",
|
"token_endpoint": base + "/oauth/token",
|
||||||
"jwks_uri": settings.Issuer + "/.well-known/jwks.json",
|
"jwks_uri": base + "/.well-known/jwks.json",
|
||||||
"grant_types_supported": settings.AllowedGrantTypes,
|
"grant_types_supported": settings.AllowedGrantTypes,
|
||||||
"response_types_supported": []string{"code"},
|
"response_types_supported": []string{"code", "token"},
|
||||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
|
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
|
||||||
"code_challenge_methods_supported": []string{"S256"},
|
"code_challenge_methods_supported": []string{"S256"},
|
||||||
"scopes_supported": []string{
|
"scopes_supported": []string{"openid", "profile", "email", "api:read", "api:write", "admin"},
|
||||||
"api:read",
|
"default_private_key_path": settings.DefaultPrivateKeyPath,
|
||||||
"api:write",
|
})
|
||||||
"admin",
|
}
|
||||||
},
|
|
||||||
|
// OAuthOIDCConfiguration OIDC discovery document
|
||||||
|
func OAuthOIDCConfiguration(c *gin.Context) {
|
||||||
|
settings := system_setting.GetOAuth2Settings()
|
||||||
|
if !settings.Enabled {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "OAuth2 server is disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
issuer := settings.Issuer
|
||||||
|
if issuer == "" {
|
||||||
|
scheme := "https"
|
||||||
|
if c.Request.TLS == nil {
|
||||||
|
if hdr := c.Request.Header.Get("X-Forwarded-Proto"); hdr != "" {
|
||||||
|
scheme = hdr
|
||||||
|
} else {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issuer = scheme + "://" + c.Request.Host
|
||||||
|
}
|
||||||
|
base := issuer + "/api"
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"issuer": issuer,
|
||||||
|
"authorization_endpoint": base + "/oauth/authorize",
|
||||||
|
"token_endpoint": base + "/oauth/token",
|
||||||
|
"userinfo_endpoint": base + "/oauth/userinfo",
|
||||||
|
"jwks_uri": base + "/.well-known/jwks.json",
|
||||||
|
"response_types_supported": []string{"code", "token"},
|
||||||
|
"grant_types_supported": settings.AllowedGrantTypes,
|
||||||
|
"subject_types_supported": []string{"public"},
|
||||||
|
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||||
|
"scopes_supported": []string{"openid", "profile", "email", "api:read", "api:write", "admin"},
|
||||||
|
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
|
||||||
|
"code_challenge_methods_supported": []string{"S256"},
|
||||||
|
"default_private_key_path": settings.DefaultPrivateKeyPath,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,14 +216,50 @@ func OAuthIntrospect(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: 实现令牌内省逻辑
|
tokenString := token
|
||||||
// 1. 验证调用者的认证信息
|
|
||||||
// 2. 解析和验证JWT令牌
|
|
||||||
// 3. 返回令牌的元信息
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
// 验证并解析JWT
|
||||||
"active": false, // 临时返回,需要实现实际的内省逻辑
|
parsed, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||||
|
return nil, jwt.ErrTokenSignatureInvalid
|
||||||
|
}
|
||||||
|
pub := oauth.GetPublicKeyByKid(func() string {
|
||||||
|
if v, ok := token.Header["kid"].(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}())
|
||||||
|
if pub == nil {
|
||||||
|
return nil, jwt.ErrTokenUnverifiable
|
||||||
|
}
|
||||||
|
return pub, nil
|
||||||
})
|
})
|
||||||
|
if err != nil || !parsed.Valid {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"active": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"active": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查撤销
|
||||||
|
if jti, ok := claims["jti"].(string); ok && jti != "" {
|
||||||
|
if revoked, _ := model.IsTokenRevoked(jti); revoked {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"active": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有效
|
||||||
|
resp := gin.H{"active": true}
|
||||||
|
for k, v := range claims {
|
||||||
|
resp[k] = v
|
||||||
|
}
|
||||||
|
resp["token_type"] = "Bearer"
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuthRevoke 令牌撤销端点(RFC 7009)
|
// OAuthRevoke 令牌撤销端点(RFC 7009)
|
||||||
@@ -190,11 +290,86 @@ func OAuthRevoke(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: 实现令牌撤销逻辑
|
token = c.PostForm("token")
|
||||||
// 1. 验证调用者的认证信息
|
if token == "" {
|
||||||
// 2. 撤销指定的令牌(加入黑名单或从存储中删除)
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Missing token parameter",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
// 尝试解析JWT,若成功则记录jti到撤销表
|
||||||
"success": true,
|
parsed, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||||
|
return nil, jwt.ErrTokenSignatureInvalid
|
||||||
|
}
|
||||||
|
pub := oauth.GetRSAPublicKey()
|
||||||
|
if pub == nil {
|
||||||
|
return nil, jwt.ErrTokenUnverifiable
|
||||||
|
}
|
||||||
|
return pub, nil
|
||||||
})
|
})
|
||||||
|
if err == nil && parsed != nil && parsed.Valid {
|
||||||
|
if claims, ok := parsed.Claims.(jwt.MapClaims); ok {
|
||||||
|
var jti string
|
||||||
|
var exp int64
|
||||||
|
if v, ok := claims["jti"].(string); ok {
|
||||||
|
jti = v
|
||||||
|
}
|
||||||
|
if v, ok := claims["exp"].(float64); ok {
|
||||||
|
exp = int64(v)
|
||||||
|
} else if v, ok := claims["exp"].(int64); ok {
|
||||||
|
exp = v
|
||||||
|
}
|
||||||
|
if jti != "" {
|
||||||
|
// 如果没有exp,默认撤销至当前+TTL 10分钟
|
||||||
|
if exp == 0 {
|
||||||
|
exp = time.Now().Add(10 * time.Minute).Unix()
|
||||||
|
}
|
||||||
|
_ = model.RevokeToken(jti, exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthUserInfo returns OIDC userinfo based on access token
|
||||||
|
func OAuthUserInfo(c *gin.Context) {
|
||||||
|
settings := system_setting.GetOAuth2Settings()
|
||||||
|
if !settings.Enabled {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "OAuth2 server is disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 需要 OAuthJWTAuth 中间件注入 claims
|
||||||
|
claims, ok := middleware.GetOAuthClaims(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// scope 校验:必须包含 openid
|
||||||
|
scope, _ := claims["scope"].(string)
|
||||||
|
if !strings.Contains(" "+scope+" ", " openid ") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient_scope"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sub, _ := claims["sub"].(string)
|
||||||
|
resp := gin.H{"sub": sub}
|
||||||
|
// 若包含 profile/email scope,补充返回
|
||||||
|
if strings.Contains(" "+scope+" ", " profile ") || strings.Contains(" "+scope+" ", " email ") {
|
||||||
|
if uid, err := strconv.Atoi(sub); err == nil {
|
||||||
|
if user, err2 := model.GetUserById(uid, false); err2 == nil && user != nil {
|
||||||
|
if strings.Contains(" "+scope+" ", " profile ") {
|
||||||
|
resp["name"] = user.DisplayName
|
||||||
|
resp["preferred_username"] = user.Username
|
||||||
|
}
|
||||||
|
if strings.Contains(" "+scope+" ", " email ") {
|
||||||
|
resp["email"] = user.Email
|
||||||
|
resp["email_verified"] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|||||||
89
controller/oauth_keys.go
Normal file
89
controller/oauth_keys.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"net/http"
|
||||||
|
"one-api/logger"
|
||||||
|
"one-api/src/oauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rotateKeyRequest struct {
|
||||||
|
Kid string `json:"kid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type genKeyFileRequest struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Kid string `json:"kid"`
|
||||||
|
Overwrite bool `json:"overwrite"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type importPemRequest struct {
|
||||||
|
Pem string `json:"pem"`
|
||||||
|
Kid string `json:"kid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotateOAuthSigningKey rotates the OAuth2 JWT signing key (Root only)
|
||||||
|
func RotateOAuthSigningKey(c *gin.Context) {
|
||||||
|
var req rotateKeyRequest
|
||||||
|
_ = c.BindJSON(&req)
|
||||||
|
kid, err := oauth.RotateSigningKey(req.Kid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.LogInfo(c, "oauth signing key rotated: "+kid)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "kid": kid})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOAuthSigningKeys returns current and historical JWKS signing keys
|
||||||
|
func ListOAuthSigningKeys(c *gin.Context) {
|
||||||
|
keys := oauth.ListSigningKeys()
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": keys})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOAuthSigningKey deletes a non-current key by kid
|
||||||
|
func DeleteOAuthSigningKey(c *gin.Context) {
|
||||||
|
kid := c.Param("kid")
|
||||||
|
if kid == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "kid required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := oauth.DeleteSigningKey(kid); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.LogInfo(c, "oauth signing key deleted: "+kid)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateOAuthSigningKeyFile generates a private key file and rotates current kid
|
||||||
|
func GenerateOAuthSigningKeyFile(c *gin.Context) {
|
||||||
|
var req genKeyFileRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.Path == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "path required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kid, err := oauth.GenerateAndPersistKey(req.Path, req.Kid, req.Overwrite)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.LogInfo(c, "oauth signing key generated to file: "+req.Path+" kid="+kid)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "kid": kid, "path": req.Path})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportOAuthSigningKey imports PEM text and rotates current kid
|
||||||
|
func ImportOAuthSigningKey(c *gin.Context) {
|
||||||
|
var req importPemRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.Pem == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "pem required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kid, err := oauth.ImportPEMKey(req.Pem, req.Kid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.LogInfo(c, "oauth signing key imported from PEM, kid="+kid)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "kid": kid})
|
||||||
|
}
|
||||||
326
examples/oauth/oauth-demo.html
Normal file
326
examples/oauth/oauth-demo.html
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>OAuth2/OIDC 授权码 + PKCE 前端演示</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg:#0b0c10; --panel:#111317; --muted:#aab2bf; --accent:#3b82f6; --ok:#16a34a; --warn:#f59e0b; --err:#ef4444; --border:#1f2430; }
|
||||||
|
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background: var(--bg); color:#e5e7eb; }
|
||||||
|
.wrap { max-width: 980px; margin: 32px auto; padding: 0 16px; }
|
||||||
|
h1 { font-size: 22px; margin:0 0 16px; }
|
||||||
|
.card { background: var(--panel); border:1px solid var(--border); border-radius: 10px; padding: 16px; margin: 12px 0; }
|
||||||
|
.row { display:flex; gap:12px; flex-wrap:wrap; }
|
||||||
|
.col { flex: 1 1 280px; display:flex; flex-direction:column; }
|
||||||
|
label { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
|
||||||
|
input, textarea, select { background:#0f1115; color:#e5e7eb; border:1px solid var(--border); padding:10px 12px; border-radius:8px; outline:none; }
|
||||||
|
textarea { min-height: 100px; resize: vertical; }
|
||||||
|
.btns { display:flex; gap:8px; flex-wrap:wrap; margin-top: 8px; }
|
||||||
|
button { background:#1a1f2b; color:#e5e7eb; border:1px solid var(--border); padding:8px 12px; border-radius:8px; cursor:pointer; }
|
||||||
|
button.primary { background: var(--accent); border-color: var(--accent); color:white; }
|
||||||
|
button.ok { background: var(--ok); border-color: var(--ok); color:white; }
|
||||||
|
button.warn { background: var(--warn); border-color: var(--warn); color:black; }
|
||||||
|
button.ghost { background: transparent; }
|
||||||
|
.muted { color: var(--muted); font-size: 12px; }
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||||
|
.grid2 { display:grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
@media (max-width: 880px){ .grid2 { grid-template-columns: 1fr; } }
|
||||||
|
.pill { padding: 3px 8px; border-radius:999px; font-size: 12px; border:1px solid var(--border); background:#0f1115; }
|
||||||
|
.ok { color: #10b981; }
|
||||||
|
.err { color: #ef4444; }
|
||||||
|
.sep { height:1px; background: var(--border); margin: 12px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>OAuth2/OIDC 授权码 + PKCE 前端演示</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<label>Issuer(可选,用于自动发现 /.well-known/openid-configuration)</label>
|
||||||
|
<input id="issuer" placeholder="https://your-domain" />
|
||||||
|
<div class="btns"><button class="" id="btnDiscover">自动发现端点</button></div>
|
||||||
|
<div class="muted">提示:若未配置 Issuer,可直接填写下方端点。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col"><label>Authorization Endpoint</label><input id="authorization_endpoint" placeholder="https://domain/api/oauth/authorize" /></div>
|
||||||
|
<div class="col"><label>Token Endpoint</label><input id="token_endpoint" placeholder="https://domain/api/oauth/token" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col"><label>UserInfo Endpoint(可选)</label><input id="userinfo_endpoint" placeholder="https://domain/api/oauth/userinfo" /></div>
|
||||||
|
<div class="col"><label>Client ID</label><input id="client_id" placeholder="your-public-client-id" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col"><label>Redirect URI(当前页地址或你的回调)</label><input id="redirect_uri" /></div>
|
||||||
|
<div class="col"><label>Scope</label><input id="scope" value="openid profile email" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col"><label>State</label><input id="state" /></div>
|
||||||
|
<div class="col"><label>Nonce</label><input id="nonce" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col"><label>Code Verifier(自动生成,不会上送)</label><input id="code_verifier" class="mono" readonly /></div>
|
||||||
|
<div class="col"><label>Code Challenge(S256)</label><input id="code_challenge" class="mono" readonly /></div>
|
||||||
|
</div>
|
||||||
|
<div class="btns">
|
||||||
|
<button id="btnGenPkce">生成 PKCE</button>
|
||||||
|
<button id="btnRandomState">随机 State</button>
|
||||||
|
<button id="btnRandomNonce">随机 Nonce</button>
|
||||||
|
<button id="btnMakeAuthURL">生成授权链接</button>
|
||||||
|
<button id="btnAuthorize" class="primary">跳转授权</button>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:8px;">
|
||||||
|
<div class="col">
|
||||||
|
<label>授权链接(只生成不跳转)</label>
|
||||||
|
<textarea id="authorize_url" class="mono" placeholder="(空)"></textarea>
|
||||||
|
<div class="btns"><button id="btnCopyAuthURL">复制链接</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sep"></div>
|
||||||
|
<div class="muted">说明:
|
||||||
|
<ul>
|
||||||
|
<li>本页为纯前端演示,适用于公开客户端(不需要 client_secret)。</li>
|
||||||
|
<li>如跨域调用 Token/UserInfo,需要服务端正确设置 CORS;建议将此 demo 部署到同源域名下。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sep"></div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<label>粘贴 OIDC Discovery JSON(/.well-known/openid-configuration)</label>
|
||||||
|
<textarea id="conf_json" class="mono" placeholder='{"issuer":"https://...","authorization_endpoint":"...","token_endpoint":"...","userinfo_endpoint":"..."}'></textarea>
|
||||||
|
<div class="btns">
|
||||||
|
<button id="btnParseConf">解析并填充端点</button>
|
||||||
|
<button id="btnGenConf">用当前端点生成 JSON</button>
|
||||||
|
</div>
|
||||||
|
<div class="muted">可将服务端返回的 OIDC Discovery JSON 粘贴到此处,点击“解析并填充端点”。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<label>授权结果</label>
|
||||||
|
<div id="authResult" class="muted">等待授权...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid2" style="margin-top:12px;">
|
||||||
|
<div>
|
||||||
|
<label>Access Token</label>
|
||||||
|
<textarea id="access_token" class="mono" placeholder="(空)"></textarea>
|
||||||
|
<div class="btns">
|
||||||
|
<button id="btnCopyAT">复制</button>
|
||||||
|
<button id="btnCallUserInfo" class="ok">调用 UserInfo</button>
|
||||||
|
</div>
|
||||||
|
<div id="userinfoOut" class="muted" style="margin-top:6px;"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>ID Token(JWT)</label>
|
||||||
|
<textarea id="id_token" class="mono" placeholder="(空)"></textarea>
|
||||||
|
<div class="btns">
|
||||||
|
<button id="btnDecodeJWT">解码显示 Claims</button>
|
||||||
|
</div>
|
||||||
|
<pre id="jwtClaims" class="mono" style="white-space:pre-wrap; word-break:break-all; margin-top:6px;"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid2" style="margin-top:12px;">
|
||||||
|
<div>
|
||||||
|
<label>Refresh Token</label>
|
||||||
|
<textarea id="refresh_token" class="mono" placeholder="(空)"></textarea>
|
||||||
|
<div class="btns">
|
||||||
|
<button id="btnRefreshToken">使用 Refresh Token 刷新</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>原始 Token 响应</label>
|
||||||
|
<textarea id="token_raw" class="mono" placeholder="(空)"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const toB64Url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
async function sha256B64Url(str){
|
||||||
|
const data = new TextEncoder().encode(str);
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
return toB64Url(digest);
|
||||||
|
}
|
||||||
|
function randStr(len=64){
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||||
|
const arr = new Uint8Array(len); crypto.getRandomValues(arr);
|
||||||
|
return Array.from(arr, v => chars[v % chars.length]).join('');
|
||||||
|
}
|
||||||
|
function setAuthInfo(msg, ok=true){
|
||||||
|
const el = $('authResult');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = ok ? 'ok' : 'err';
|
||||||
|
}
|
||||||
|
function qs(name){ const u=new URL(location.href); return u.searchParams.get(name); }
|
||||||
|
|
||||||
|
function persist(name, val){ sessionStorage.setItem('demo_'+name, val); }
|
||||||
|
function load(name){ return sessionStorage.getItem('demo_'+name) || ''; }
|
||||||
|
|
||||||
|
// init defaults
|
||||||
|
(function init(){
|
||||||
|
$('redirect_uri').value = window.location.origin + window.location.pathname;
|
||||||
|
// try load from discovery if issuer saved previously
|
||||||
|
const iss = load('issuer'); if(iss) $('issuer').value = iss;
|
||||||
|
const cid = load('client_id'); if(cid) $('client_id').value = cid;
|
||||||
|
const scp = load('scope'); if(scp) $('scope').value = scp;
|
||||||
|
})();
|
||||||
|
|
||||||
|
$('btnDiscover').onclick = async () => {
|
||||||
|
const iss = $('issuer').value.trim(); if(!iss){ alert('请填写 Issuer'); return; }
|
||||||
|
try{
|
||||||
|
persist('issuer', iss);
|
||||||
|
const res = await fetch(iss.replace(/\/$/,'') + '/api/.well-known/openid-configuration');
|
||||||
|
const d = await res.json();
|
||||||
|
$('authorization_endpoint').value = d.authorization_endpoint || '';
|
||||||
|
$('token_endpoint').value = d.token_endpoint || '';
|
||||||
|
$('userinfo_endpoint').value = d.userinfo_endpoint || '';
|
||||||
|
if (d.issuer) { $('issuer').value = d.issuer; persist('issuer', d.issuer); }
|
||||||
|
$('conf_json').value = JSON.stringify(d, null, 2);
|
||||||
|
setAuthInfo('已从发现文档加载端点', true);
|
||||||
|
}catch(e){ setAuthInfo('自动发现失败:'+e, false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
$('btnGenPkce').onclick = async () => {
|
||||||
|
const v = randStr(64); const c = await sha256B64Url(v);
|
||||||
|
$('code_verifier').value = v; $('code_challenge').value = c;
|
||||||
|
persist('code_verifier', v); persist('code_challenge', c);
|
||||||
|
setAuthInfo('已生成 PKCE 参数', true);
|
||||||
|
};
|
||||||
|
$('btnRandomState').onclick = () => { $('state').value = randStr(16); persist('state', $('state').value); };
|
||||||
|
$('btnRandomNonce').onclick = () => { $('nonce').value = randStr(16); persist('nonce', $('nonce').value); };
|
||||||
|
|
||||||
|
function buildAuthorizeURLFromFields() {
|
||||||
|
const auth = $('authorization_endpoint').value.trim();
|
||||||
|
const token = $('token_endpoint').value.trim(); // just validate
|
||||||
|
const cid = $('client_id').value.trim();
|
||||||
|
const red = $('redirect_uri').value.trim();
|
||||||
|
const scp = $('scope').value.trim() || 'openid profile email';
|
||||||
|
const st = $('state').value.trim() || randStr(16);
|
||||||
|
const no = $('nonce').value.trim() || randStr(16);
|
||||||
|
const cc = $('code_challenge').value.trim();
|
||||||
|
const cv = $('code_verifier').value.trim();
|
||||||
|
if(!auth || !token || !cid || !red){ throw new Error('请先完善端点/ClientID/RedirectURI'); }
|
||||||
|
if(!cc || !cv){ throw new Error('请先生成 PKCE'); }
|
||||||
|
persist('authorization_endpoint', auth); persist('token_endpoint', token);
|
||||||
|
persist('client_id', cid); persist('redirect_uri', red); persist('scope', scp);
|
||||||
|
persist('state', st); persist('nonce', no); persist('code_verifier', cv);
|
||||||
|
const u = new URL(auth);
|
||||||
|
u.searchParams.set('response_type', 'code');
|
||||||
|
u.searchParams.set('client_id', cid);
|
||||||
|
u.searchParams.set('redirect_uri', red);
|
||||||
|
u.searchParams.set('scope', scp);
|
||||||
|
u.searchParams.set('state', st);
|
||||||
|
u.searchParams.set('nonce', no);
|
||||||
|
u.searchParams.set('code_challenge', cc);
|
||||||
|
u.searchParams.set('code_challenge_method', 'S256');
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
$('btnMakeAuthURL').onclick = () => {
|
||||||
|
try {
|
||||||
|
const url = buildAuthorizeURLFromFields();
|
||||||
|
$('authorize_url').value = url;
|
||||||
|
setAuthInfo('已生成授权链接', true);
|
||||||
|
} catch(e){ setAuthInfo(e.message, false); }
|
||||||
|
};
|
||||||
|
$('btnAuthorize').onclick = () => {
|
||||||
|
try { const url = buildAuthorizeURLFromFields(); location.href = url; }
|
||||||
|
catch(e){ setAuthInfo(e.message, false); }
|
||||||
|
};
|
||||||
|
$('btnCopyAuthURL').onclick = async () => { try{ await navigator.clipboard.writeText($('authorize_url').value); }catch{} };
|
||||||
|
|
||||||
|
// Parse OIDC discovery JSON pasted by user
|
||||||
|
$('btnParseConf').onclick = () => {
|
||||||
|
const txt = $('conf_json').value.trim(); if(!txt){ alert('请先粘贴 JSON'); return; }
|
||||||
|
try{
|
||||||
|
const d = JSON.parse(txt);
|
||||||
|
if (d.issuer) { $('issuer').value = d.issuer; persist('issuer', d.issuer); }
|
||||||
|
if (d.authorization_endpoint) $('authorization_endpoint').value = d.authorization_endpoint;
|
||||||
|
if (d.token_endpoint) $('token_endpoint').value = d.token_endpoint;
|
||||||
|
if (d.userinfo_endpoint) $('userinfo_endpoint').value = d.userinfo_endpoint;
|
||||||
|
setAuthInfo('已解析配置并填充端点', true);
|
||||||
|
}catch(e){ setAuthInfo('解析失败:'+e, false); }
|
||||||
|
};
|
||||||
|
// Generate a minimal discovery JSON from current fields
|
||||||
|
$('btnGenConf').onclick = () => {
|
||||||
|
const d = {
|
||||||
|
issuer: $('issuer').value.trim() || undefined,
|
||||||
|
authorization_endpoint: $('authorization_endpoint').value.trim() || undefined,
|
||||||
|
token_endpoint: $('token_endpoint').value.trim() || undefined,
|
||||||
|
userinfo_endpoint: $('userinfo_endpoint').value.trim() || undefined,
|
||||||
|
};
|
||||||
|
$('conf_json').value = JSON.stringify(d, null, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function postForm(url, data){
|
||||||
|
const body = Object.entries(data).map(([k,v])=> `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
|
||||||
|
const res = await fetch(url, { method:'POST', headers:{ 'Content-Type':'application/x-www-form-urlencoded' }, body });
|
||||||
|
if(!res.ok){ const t = await res.text(); throw new Error(`HTTP ${res.status} ${t}`); }
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCallback(){
|
||||||
|
const code = qs('code'); const err = qs('error');
|
||||||
|
const state = qs('state');
|
||||||
|
if(err){ setAuthInfo('授权失败:'+err, false); return; }
|
||||||
|
if(!code){ setAuthInfo('等待授权...', true); return; }
|
||||||
|
// state check
|
||||||
|
if(state && load('state') && state !== load('state')){ setAuthInfo('state 不匹配,已拒绝', false); return; }
|
||||||
|
try{
|
||||||
|
const tokenEp = load('token_endpoint');
|
||||||
|
const data = await postForm(tokenEp, {
|
||||||
|
grant_type:'authorization_code',
|
||||||
|
code,
|
||||||
|
client_id: load('client_id'),
|
||||||
|
redirect_uri: load('redirect_uri'),
|
||||||
|
code_verifier: load('code_verifier')
|
||||||
|
});
|
||||||
|
$('access_token').value = data.access_token || '';
|
||||||
|
$('id_token').value = data.id_token || '';
|
||||||
|
$('refresh_token').value = data.refresh_token || '';
|
||||||
|
$('token_raw').value = JSON.stringify(data, null, 2);
|
||||||
|
setAuthInfo('授权成功,已获取令牌', true);
|
||||||
|
}catch(e){ setAuthInfo('交换令牌失败:'+e.message, false); }
|
||||||
|
}
|
||||||
|
handleCallback();
|
||||||
|
|
||||||
|
$('btnCopyAT').onclick = async () => { try{ await navigator.clipboard.writeText($('access_token').value); }catch{} };
|
||||||
|
$('btnDecodeJWT').onclick = () => {
|
||||||
|
const t = $('id_token').value.trim(); if(!t){ $('jwtClaims').textContent='(空)'; return; }
|
||||||
|
const parts = t.split('.'); if(parts.length<2){ $('jwtClaims').textContent='格式错误'; return; }
|
||||||
|
try{ const json = JSON.parse(atob(parts[1].replace(/-/g,'+').replace(/_/g,'/'))); $('jwtClaims').textContent = JSON.stringify(json, null, 2);}catch(e){ $('jwtClaims').textContent='解码失败:'+e; }
|
||||||
|
};
|
||||||
|
$('btnCallUserInfo').onclick = async () => {
|
||||||
|
const at = $('access_token').value.trim(); const ep = $('userinfo_endpoint').value.trim(); if(!at||!ep){ alert('请填写UserInfo端点并获取AccessToken'); return; }
|
||||||
|
try{
|
||||||
|
const res = await fetch(ep, { headers:{ Authorization: 'Bearer '+at } });
|
||||||
|
const data = await res.json(); $('userinfoOut').textContent = JSON.stringify(data, null, 2);
|
||||||
|
}catch(e){ $('userinfoOut').textContent = '调用失败:'+e; }
|
||||||
|
};
|
||||||
|
$('btnRefreshToken').onclick = async () => {
|
||||||
|
const rt = $('refresh_token').value.trim(); if(!rt){ alert('没有刷新令牌'); return; }
|
||||||
|
try{
|
||||||
|
const tokenEp = load('token_endpoint');
|
||||||
|
const data = await postForm(tokenEp, {
|
||||||
|
grant_type:'refresh_token',
|
||||||
|
refresh_token: rt,
|
||||||
|
client_id: load('client_id')
|
||||||
|
});
|
||||||
|
$('access_token').value = data.access_token || '';
|
||||||
|
$('id_token').value = data.id_token || '';
|
||||||
|
$('refresh_token').value = data.refresh_token || '';
|
||||||
|
$('token_raw').value = JSON.stringify(data, null, 2);
|
||||||
|
setAuthInfo('刷新成功', true);
|
||||||
|
}catch(e){ setAuthInfo('刷新失败:'+e.message, false); }
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
181
examples/oauth/oauth2_test_client.go
Normal file
181
examples/oauth/oauth2_test_client.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"golang.org/x/oauth2/clientcredentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 测试 Client Credentials 流程
|
||||||
|
//testClientCredentials()
|
||||||
|
|
||||||
|
// 测试 Authorization Code + PKCE 流程(需要浏览器交互)
|
||||||
|
testAuthorizationCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// testClientCredentials 测试服务对服务认证
|
||||||
|
func testClientCredentials() {
|
||||||
|
fmt.Println("=== Testing Client Credentials Flow ===")
|
||||||
|
|
||||||
|
cfg := clientcredentials.Config{
|
||||||
|
ClientID: "client_dsFyyoyNZWjhbNa2", // 需要先创建客户端
|
||||||
|
ClientSecret: "hLLdn2Ia4UM7hcsJaSuUFDV0Px9BrkNq",
|
||||||
|
TokenURL: "http://localhost:3000/api/oauth/token",
|
||||||
|
Scopes: []string{"api:read", "api:write"},
|
||||||
|
EndpointParams: map[string][]string{
|
||||||
|
"audience": {"api://new-api"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建HTTP客户端
|
||||||
|
httpClient := cfg.Client(context.Background())
|
||||||
|
|
||||||
|
// 调用受保护的API
|
||||||
|
resp, err := httpClient.Get("http://localhost:3000/api/status")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Request failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to read response: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Status: %s\n", resp.Status)
|
||||||
|
fmt.Printf("Response: %s\n", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// testAuthorizationCode 测试授权码流程
|
||||||
|
func testAuthorizationCode() {
|
||||||
|
fmt.Println("=== Testing Authorization Code + PKCE Flow ===")
|
||||||
|
|
||||||
|
conf := oauth2.Config{
|
||||||
|
ClientID: "client_dsFyyoyNZWjhbNa2", // 需要先创建客户端
|
||||||
|
ClientSecret: "JHiugKf89OMmTLuZMZyA2sgZnO0Ioae3",
|
||||||
|
RedirectURL: "http://localhost:9999/callback",
|
||||||
|
// 包含 openid/profile/email 以便调用 UserInfo
|
||||||
|
Scopes: []string{"openid", "profile", "email", "api:read"},
|
||||||
|
Endpoint: oauth2.Endpoint{
|
||||||
|
AuthURL: "http://localhost:3000/api/oauth/authorize",
|
||||||
|
TokenURL: "http://localhost:3000/api/oauth/token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成PKCE参数
|
||||||
|
codeVerifier := oauth2.GenerateVerifier()
|
||||||
|
state := fmt.Sprintf("state-%d", time.Now().Unix())
|
||||||
|
|
||||||
|
// 构建授权URL
|
||||||
|
url := conf.AuthCodeURL(
|
||||||
|
state,
|
||||||
|
oauth2.S256ChallengeOption(codeVerifier),
|
||||||
|
//oauth2.SetAuthURLParam("audience", "api://new-api"),
|
||||||
|
)
|
||||||
|
|
||||||
|
fmt.Printf("Visit this URL to authorize:\n%s\n\n", url)
|
||||||
|
fmt.Printf("A local server will listen on http://localhost:9999/callback to receive the code...\n")
|
||||||
|
|
||||||
|
// 启动回调本地服务器,自动接收授权码
|
||||||
|
codeCh := make(chan string, 1)
|
||||||
|
srv := &http.Server{Addr: ":9999"}
|
||||||
|
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
if errParam := q.Get("error"); errParam != "" {
|
||||||
|
fmt.Fprintf(w, "Authorization failed: %s", errParam)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gotState := q.Get("state")
|
||||||
|
if gotState != state {
|
||||||
|
http.Error(w, "state mismatch", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := q.Get("code")
|
||||||
|
if code == "" {
|
||||||
|
http.Error(w, "missing code", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, "Authorization received. You may close this window.")
|
||||||
|
select {
|
||||||
|
case codeCh <- code:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
// 稍后关闭服务
|
||||||
|
_ = srv.Shutdown(context.Background())
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
go func() {
|
||||||
|
_ = srv.ListenAndServe()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 等待授权码
|
||||||
|
var code string
|
||||||
|
select {
|
||||||
|
case code = <-codeCh:
|
||||||
|
case <-time.After(5 * time.Minute):
|
||||||
|
log.Println("Timeout waiting for authorization code")
|
||||||
|
_ = srv.Shutdown(context.Background())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交换令牌
|
||||||
|
token, err := conf.Exchange(
|
||||||
|
context.Background(),
|
||||||
|
code,
|
||||||
|
oauth2.VerifierOption(codeVerifier),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Token exchange failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Access Token: %s\n", token.AccessToken)
|
||||||
|
fmt.Printf("Token Type: %s\n", token.TokenType)
|
||||||
|
fmt.Printf("Expires In: %v\n", token.Expiry)
|
||||||
|
|
||||||
|
// 使用令牌调用 UserInfo
|
||||||
|
client := conf.Client(context.Background(), token)
|
||||||
|
userInfoURL := buildUserInfoFromAuth(conf.Endpoint.AuthURL)
|
||||||
|
resp, err := client.Get(userInfoURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("UserInfo request failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to read UserInfo response: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("UserInfo: %s\n", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildUserInfoFromAuth 将授权端点URL转换为UserInfo端点URL
|
||||||
|
func buildUserInfoFromAuth(auth string) string {
|
||||||
|
u, err := url.Parse(auth)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// 将最后一个路径段 authorize 替换为 userinfo
|
||||||
|
dir := path.Dir(u.Path)
|
||||||
|
if strings.HasSuffix(u.Path, "/authorize") {
|
||||||
|
u.Path = path.Join(dir, "userinfo")
|
||||||
|
} else {
|
||||||
|
// 回退:追加默认 /oauth/userinfo
|
||||||
|
u.Path = path.Join(dir, "userinfo")
|
||||||
|
}
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,11 +8,14 @@ import (
|
|||||||
"one-api/model"
|
"one-api/model"
|
||||||
"one-api/setting"
|
"one-api/setting"
|
||||||
"one-api/setting/ratio_setting"
|
"one-api/setting/ratio_setting"
|
||||||
|
"one-api/setting/system_setting"
|
||||||
|
"one-api/src/oauth"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
jwt "github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
func validUserInfo(username string, role int) bool {
|
func validUserInfo(username string, role int) bool {
|
||||||
@@ -177,6 +180,7 @@ func WssAuth(c *gin.Context) {
|
|||||||
|
|
||||||
func TokenAuth() func(c *gin.Context) {
|
func TokenAuth() func(c *gin.Context) {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
rawAuth := c.Request.Header.Get("Authorization")
|
||||||
// 先检测是否为ws
|
// 先检测是否为ws
|
||||||
if c.Request.Header.Get("Sec-WebSocket-Protocol") != "" {
|
if c.Request.Header.Get("Sec-WebSocket-Protocol") != "" {
|
||||||
// Sec-WebSocket-Protocol: realtime, openai-insecure-api-key.sk-xxx, openai-beta.realtime-v1
|
// Sec-WebSocket-Protocol: realtime, openai-insecure-api-key.sk-xxx, openai-beta.realtime-v1
|
||||||
@@ -235,6 +239,11 @@ func TokenAuth() func(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// OAuth Bearer fallback
|
||||||
|
if tryOAuthBearer(c, rawAuth) {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error())
|
abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -288,6 +297,74 @@ func TokenAuth() func(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tryOAuthBearer validates an OAuth JWT access token and sets minimal context for relay
|
||||||
|
func tryOAuthBearer(c *gin.Context, rawAuth string) bool {
|
||||||
|
if rawAuth == "" || !strings.HasPrefix(rawAuth, "Bearer ") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tokenString := strings.TrimSpace(strings.TrimPrefix(rawAuth, "Bearer "))
|
||||||
|
if tokenString == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
settings := system_setting.GetOAuth2Settings()
|
||||||
|
// Parse & verify
|
||||||
|
parsed, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||||
|
return nil, jwt.ErrTokenSignatureInvalid
|
||||||
|
}
|
||||||
|
if kid, ok := t.Header["kid"].(string); ok {
|
||||||
|
if settings.JWTKeyID != "" && kid != settings.JWTKeyID {
|
||||||
|
return nil, jwt.ErrTokenSignatureInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub := oauth.GetRSAPublicKey()
|
||||||
|
if pub == nil {
|
||||||
|
return nil, jwt.ErrTokenUnverifiable
|
||||||
|
}
|
||||||
|
return pub, nil
|
||||||
|
})
|
||||||
|
if err != nil || parsed == nil || !parsed.Valid {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// issuer check when configured
|
||||||
|
if iss, ok2 := claims["iss"].(string); !ok2 || (settings.Issuer != "" && iss != settings.Issuer) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// revoke check
|
||||||
|
if jti, ok2 := claims["jti"].(string); ok2 && jti != "" {
|
||||||
|
if revoked, _ := model.IsTokenRevoked(jti); revoked {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// scope check: must contain api:read or api:write or admin
|
||||||
|
scope, _ := claims["scope"].(string)
|
||||||
|
scopePadded := " " + scope + " "
|
||||||
|
if !(strings.Contains(scopePadded, " api:read ") || strings.Contains(scopePadded, " api:write ") || strings.Contains(scopePadded, " admin ")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// subject must be user id to support quota logic
|
||||||
|
sub, _ := claims["sub"].(string)
|
||||||
|
uid, err := strconv.Atoi(sub)
|
||||||
|
if err != nil || uid <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// load user cache & set context
|
||||||
|
userCache, err := model.GetUserCache(uid)
|
||||||
|
if err != nil || userCache == nil || userCache.Status != common.UserStatusEnabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c.Set("id", uid)
|
||||||
|
c.Set("group", userCache.Group)
|
||||||
|
c.Set("user_group", userCache.Group)
|
||||||
|
// set UsingGroup
|
||||||
|
common.SetContextKey(c, constant.ContextKeyUsingGroup, userCache.Group)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) error {
|
func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) error {
|
||||||
if token == nil {
|
if token == nil {
|
||||||
return fmt.Errorf("token is nil")
|
return fmt.Errorf("token is nil")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"one-api/common"
|
"one-api/common"
|
||||||
"one-api/model"
|
"one-api/model"
|
||||||
"one-api/setting/system_setting"
|
"one-api/setting/system_setting"
|
||||||
|
"one-api/src/oauth"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -108,23 +109,20 @@ func getPublicKeyByKid(kid string) (*rsa.PublicKey, error) {
|
|||||||
// 这里先实现一个简单版本
|
// 这里先实现一个简单版本
|
||||||
|
|
||||||
// TODO: 实现JWKS缓存和刷新机制
|
// TODO: 实现JWKS缓存和刷新机制
|
||||||
settings := system_setting.GetOAuth2Settings()
|
pub := oauth.GetPublicKeyByKid(kid)
|
||||||
if settings.JWTKeyID == kid {
|
if pub == nil {
|
||||||
// 从OAuth server模块获取公钥
|
return nil, fmt.Errorf("unknown kid: %s", kid)
|
||||||
// 这需要在OAuth server初始化后才能使用
|
|
||||||
return nil, fmt.Errorf("JWKS functionality not yet implemented")
|
|
||||||
}
|
}
|
||||||
|
return pub, nil
|
||||||
return nil, fmt.Errorf("unknown kid: %s", kid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateOAuthClaims 验证OAuth2 claims
|
// validateOAuthClaims 验证OAuth2 claims
|
||||||
func validateOAuthClaims(claims jwt.MapClaims) error {
|
func validateOAuthClaims(claims jwt.MapClaims) error {
|
||||||
settings := system_setting.GetOAuth2Settings()
|
settings := system_setting.GetOAuth2Settings()
|
||||||
|
|
||||||
// 验证issuer
|
// 验证issuer(若配置了 Issuer 则强校验,否则仅要求存在)
|
||||||
if iss, ok := claims["iss"].(string); ok {
|
if iss, ok := claims["iss"].(string); ok {
|
||||||
if iss != settings.Issuer {
|
if settings.Issuer != "" && iss != settings.Issuer {
|
||||||
return fmt.Errorf("invalid issuer")
|
return fmt.Errorf("invalid issuer")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -146,6 +144,14 @@ func validateOAuthClaims(claims jwt.MapClaims) error {
|
|||||||
if client.Status != common.UserStatusEnabled {
|
if client.Status != common.UserStatusEnabled {
|
||||||
return fmt.Errorf("client disabled")
|
return fmt.Errorf("client disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否被撤销
|
||||||
|
if jti, ok := claims["jti"].(string); ok && jti != "" {
|
||||||
|
revoked, _ := model.IsTokenRevoked(jti)
|
||||||
|
if revoked {
|
||||||
|
return fmt.Errorf("token revoked")
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("missing client_id claim")
|
return fmt.Errorf("missing client_id claim")
|
||||||
}
|
}
|
||||||
@@ -240,6 +246,34 @@ func OptionalOAuthAuth() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequireOAuthScopeIfPresent enforces scope only when OAuth is present; otherwise no-op
|
||||||
|
func RequireOAuthScopeIfPresent(requiredScope string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if !c.GetBool("oauth_authenticated") {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scope, exists := c.Get("oauth_scope")
|
||||||
|
if !exists {
|
||||||
|
abortWithOAuthError(c, "insufficient_scope", "No scope in token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scopeStr, ok := scope.(string)
|
||||||
|
if !ok {
|
||||||
|
abortWithOAuthError(c, "insufficient_scope", "Invalid scope format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scopes := strings.Split(scopeStr, " ")
|
||||||
|
for _, s := range scopes {
|
||||||
|
if strings.TrimSpace(s) == requiredScope {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
abortWithOAuthError(c, "insufficient_scope", fmt.Sprintf("Required scope: %s", requiredScope))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetOAuthClaims 获取OAuth claims
|
// GetOAuthClaims 获取OAuth claims
|
||||||
func GetOAuthClaims(c *gin.Context) (jwt.MapClaims, bool) {
|
func GetOAuthClaims(c *gin.Context) (jwt.MapClaims, bool) {
|
||||||
claims, exists := c.Get("oauth_claims")
|
claims, exists := c.Get("oauth_claims")
|
||||||
|
|||||||
57
model/oauth_revoked_token.go
Normal file
57
model/oauth_revoked_token.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"one-api/common"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var revokedMem sync.Map // jti -> exp(unix)
|
||||||
|
|
||||||
|
func RevokeToken(jti string, exp int64) error {
|
||||||
|
if jti == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Prefer Redis, else in-memory
|
||||||
|
if common.RedisEnabled {
|
||||||
|
ttl := time.Duration(0)
|
||||||
|
if exp > 0 {
|
||||||
|
ttl = time.Until(time.Unix(exp, 0))
|
||||||
|
}
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = time.Minute
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("oauth:revoked:%s", jti)
|
||||||
|
return common.RedisSet(key, "1", ttl)
|
||||||
|
}
|
||||||
|
if exp <= 0 {
|
||||||
|
exp = time.Now().Add(time.Minute).Unix()
|
||||||
|
}
|
||||||
|
revokedMem.Store(jti, exp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsTokenRevoked(jti string) (bool, error) {
|
||||||
|
if jti == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if common.RedisEnabled {
|
||||||
|
key := fmt.Sprintf("oauth:revoked:%s", jti)
|
||||||
|
if _, err := common.RedisGet(key); err == nil {
|
||||||
|
return true, nil
|
||||||
|
} else {
|
||||||
|
// Not found or error; treat as not revoked on error to avoid hard failures
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// In-memory check
|
||||||
|
if v, ok := revokedMem.Load(jti); ok {
|
||||||
|
exp, _ := v.(int64)
|
||||||
|
if exp == 0 || time.Now().Unix() <= exp {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
revokedMem.Delete(jti)
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@@ -34,11 +34,13 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
|
|
||||||
// OAuth2 Server endpoints
|
// OAuth2 Server endpoints
|
||||||
apiRouter.GET("/.well-known/jwks.json", controller.GetJWKS)
|
apiRouter.GET("/.well-known/jwks.json", controller.GetJWKS)
|
||||||
|
apiRouter.GET("/.well-known/openid-configuration", controller.OAuthOIDCConfiguration)
|
||||||
apiRouter.GET("/.well-known/oauth-authorization-server", controller.OAuthServerInfo)
|
apiRouter.GET("/.well-known/oauth-authorization-server", controller.OAuthServerInfo)
|
||||||
apiRouter.POST("/oauth/token", middleware.CriticalRateLimit(), controller.OAuthTokenEndpoint)
|
apiRouter.POST("/oauth/token", middleware.CriticalRateLimit(), controller.OAuthTokenEndpoint)
|
||||||
apiRouter.GET("/oauth/authorize", controller.OAuthAuthorizeEndpoint)
|
apiRouter.GET("/oauth/authorize", controller.OAuthAuthorizeEndpoint)
|
||||||
apiRouter.POST("/oauth/introspect", middleware.AdminAuth(), controller.OAuthIntrospect)
|
apiRouter.POST("/oauth/introspect", middleware.AdminAuth(), controller.OAuthIntrospect)
|
||||||
apiRouter.POST("/oauth/revoke", middleware.CriticalRateLimit(), controller.OAuthRevoke)
|
apiRouter.POST("/oauth/revoke", middleware.CriticalRateLimit(), controller.OAuthRevoke)
|
||||||
|
apiRouter.GET("/oauth/userinfo", middleware.OAuthJWTAuth(), controller.OAuthUserInfo)
|
||||||
|
|
||||||
// OAuth2 管理API (前端使用)
|
// OAuth2 管理API (前端使用)
|
||||||
apiRouter.GET("/oauth/jwks", controller.GetJWKS)
|
apiRouter.GET("/oauth/jwks", controller.GetJWKS)
|
||||||
@@ -53,6 +55,17 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
|
|
||||||
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
|
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
|
||||||
|
|
||||||
|
// OAuth2 admin operations
|
||||||
|
oauthAdmin := apiRouter.Group("/oauth")
|
||||||
|
oauthAdmin.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.RootAuth())
|
||||||
|
{
|
||||||
|
oauthAdmin.POST("/keys/rotate", controller.RotateOAuthSigningKey)
|
||||||
|
oauthAdmin.GET("/keys", controller.ListOAuthSigningKeys)
|
||||||
|
oauthAdmin.DELETE("/keys/:kid", controller.DeleteOAuthSigningKey)
|
||||||
|
oauthAdmin.POST("/keys/generate_file", controller.GenerateOAuthSigningKeyFile)
|
||||||
|
oauthAdmin.POST("/keys/import_pem", controller.ImportOAuthSigningKey)
|
||||||
|
}
|
||||||
|
|
||||||
userRoute := apiRouter.Group("/user")
|
userRoute := apiRouter.Group("/user")
|
||||||
{
|
{
|
||||||
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
|
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
|
||||||
@@ -91,7 +104,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
adminRoute := userRoute.Group("/")
|
adminRoute := userRoute.Group("/")
|
||||||
adminRoute.Use(middleware.AdminAuth())
|
adminRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||||
{
|
{
|
||||||
adminRoute.GET("/", controller.GetAllUsers)
|
adminRoute.GET("/", controller.GetAllUsers)
|
||||||
adminRoute.GET("/search", controller.SearchUsers)
|
adminRoute.GET("/search", controller.SearchUsers)
|
||||||
@@ -107,7 +120,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
optionRoute := apiRouter.Group("/option")
|
optionRoute := apiRouter.Group("/option")
|
||||||
optionRoute.Use(middleware.RootAuth())
|
optionRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.RootAuth())
|
||||||
{
|
{
|
||||||
optionRoute.GET("/", controller.GetOptions)
|
optionRoute.GET("/", controller.GetOptions)
|
||||||
optionRoute.PUT("/", controller.UpdateOption)
|
optionRoute.PUT("/", controller.UpdateOption)
|
||||||
@@ -121,7 +134,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios)
|
ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios)
|
||||||
}
|
}
|
||||||
channelRoute := apiRouter.Group("/channel")
|
channelRoute := apiRouter.Group("/channel")
|
||||||
channelRoute.Use(middleware.AdminAuth())
|
channelRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||||
{
|
{
|
||||||
channelRoute.GET("/", controller.GetAllChannels)
|
channelRoute.GET("/", controller.GetAllChannels)
|
||||||
channelRoute.GET("/search", controller.SearchChannels)
|
channelRoute.GET("/search", controller.SearchChannels)
|
||||||
@@ -172,7 +185,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
redemptionRoute := apiRouter.Group("/redemption")
|
redemptionRoute := apiRouter.Group("/redemption")
|
||||||
redemptionRoute.Use(middleware.AdminAuth())
|
redemptionRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||||
{
|
{
|
||||||
redemptionRoute.GET("/", controller.GetAllRedemptions)
|
redemptionRoute.GET("/", controller.GetAllRedemptions)
|
||||||
redemptionRoute.GET("/search", controller.SearchRedemptions)
|
redemptionRoute.GET("/search", controller.SearchRedemptions)
|
||||||
@@ -200,13 +213,13 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
logRoute.GET("/token", controller.GetLogByKey)
|
logRoute.GET("/token", controller.GetLogByKey)
|
||||||
}
|
}
|
||||||
groupRoute := apiRouter.Group("/group")
|
groupRoute := apiRouter.Group("/group")
|
||||||
groupRoute.Use(middleware.AdminAuth())
|
groupRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||||
{
|
{
|
||||||
groupRoute.GET("/", controller.GetGroups)
|
groupRoute.GET("/", controller.GetGroups)
|
||||||
}
|
}
|
||||||
|
|
||||||
prefillGroupRoute := apiRouter.Group("/prefill_group")
|
prefillGroupRoute := apiRouter.Group("/prefill_group")
|
||||||
prefillGroupRoute.Use(middleware.AdminAuth())
|
prefillGroupRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||||
{
|
{
|
||||||
prefillGroupRoute.GET("/", controller.GetPrefillGroups)
|
prefillGroupRoute.GET("/", controller.GetPrefillGroups)
|
||||||
prefillGroupRoute.POST("/", controller.CreatePrefillGroup)
|
prefillGroupRoute.POST("/", controller.CreatePrefillGroup)
|
||||||
|
|||||||
@@ -3,19 +3,21 @@ package system_setting
|
|||||||
import "one-api/setting/config"
|
import "one-api/setting/config"
|
||||||
|
|
||||||
type OAuth2Settings struct {
|
type OAuth2Settings struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Issuer string `json:"issuer"`
|
Issuer string `json:"issuer"`
|
||||||
AccessTokenTTL int `json:"access_token_ttl"` // in minutes
|
AccessTokenTTL int `json:"access_token_ttl"` // in minutes
|
||||||
RefreshTokenTTL int `json:"refresh_token_ttl"` // in minutes
|
RefreshTokenTTL int `json:"refresh_token_ttl"` // in minutes
|
||||||
AllowedGrantTypes []string `json:"allowed_grant_types"` // client_credentials, authorization_code, refresh_token
|
AllowedGrantTypes []string `json:"allowed_grant_types"` // client_credentials, authorization_code, refresh_token
|
||||||
RequirePKCE bool `json:"require_pkce"` // force PKCE for authorization code flow
|
RequirePKCE bool `json:"require_pkce"` // force PKCE for authorization code flow
|
||||||
JWTSigningAlgorithm string `json:"jwt_signing_algorithm"`
|
JWTSigningAlgorithm string `json:"jwt_signing_algorithm"`
|
||||||
JWTKeyID string `json:"jwt_key_id"`
|
JWTKeyID string `json:"jwt_key_id"`
|
||||||
JWTPrivateKeyFile string `json:"jwt_private_key_file"`
|
JWTPrivateKeyFile string `json:"jwt_private_key_file"`
|
||||||
AutoCreateUser bool `json:"auto_create_user"` // auto create user on first OAuth2 login
|
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
|
DefaultUserRole int `json:"default_user_role"` // default role for auto-created users
|
||||||
DefaultUserGroup string `json:"default_user_group"` // default group 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
|
ScopeMappings map[string][]string `json:"scope_mappings"` // scope to permissions mapping
|
||||||
|
MaxJWKSKeys int `json:"max_jwks_keys"` // maximum number of JWKS signing keys to retain
|
||||||
|
DefaultPrivateKeyPath string `json:"default_private_key_path"` // suggested private key file path
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认配置
|
// 默认配置
|
||||||
@@ -35,6 +37,8 @@ var defaultOAuth2Settings = OAuth2Settings{
|
|||||||
"api:write": {"write"},
|
"api:write": {"write"},
|
||||||
"admin": {"admin"},
|
"admin": {"admin"},
|
||||||
},
|
},
|
||||||
|
MaxJWKSKeys: 3,
|
||||||
|
DefaultPrivateKeyPath: "/etc/new-api/oauth2-private.pem",
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
82
src/oauth/store.go
Normal file
82
src/oauth/store.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package oauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"one-api/common"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KVStore is a minimal TTL key-value abstraction used by OAuth flows.
|
||||||
|
type KVStore interface {
|
||||||
|
Set(key, value string, ttl time.Duration) error
|
||||||
|
Get(key string) (string, bool)
|
||||||
|
Del(key string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type redisStore struct{}
|
||||||
|
|
||||||
|
func (r *redisStore) Set(key, value string, ttl time.Duration) error {
|
||||||
|
return common.RedisSet(key, value, ttl)
|
||||||
|
}
|
||||||
|
func (r *redisStore) Get(key string) (string, bool) {
|
||||||
|
v, err := common.RedisGet(key)
|
||||||
|
if err != nil || v == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
func (r *redisStore) Del(key string) error {
|
||||||
|
return common.RedisDel(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
type memEntry struct {
|
||||||
|
val string
|
||||||
|
exp int64 // unix seconds, 0 means no expiry
|
||||||
|
}
|
||||||
|
|
||||||
|
type memoryStore struct {
|
||||||
|
m sync.Map // key -> memEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryStore) Set(key, value string, ttl time.Duration) error {
|
||||||
|
var exp int64
|
||||||
|
if ttl > 0 {
|
||||||
|
exp = time.Now().Add(ttl).Unix()
|
||||||
|
}
|
||||||
|
m.m.Store(key, memEntry{val: value, exp: exp})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryStore) Get(key string) (string, bool) {
|
||||||
|
v, ok := m.m.Load(key)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
e := v.(memEntry)
|
||||||
|
if e.exp > 0 && time.Now().Unix() > e.exp {
|
||||||
|
m.m.Delete(key)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return e.val, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryStore) Del(key string) error {
|
||||||
|
m.m.Delete(key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
memStore = &memoryStore{}
|
||||||
|
rdsStore = &redisStore{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func getStore() KVStore {
|
||||||
|
if common.RedisEnabled {
|
||||||
|
return rdsStore
|
||||||
|
}
|
||||||
|
return memStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeSet(key, val string, ttl time.Duration) error { return getStore().Set(key, val, ttl) }
|
||||||
|
func storeGet(key string) (string, bool) { return getStore().Get(key) }
|
||||||
|
func storeDel(key string) error { return getStore().Del(key) }
|
||||||
59
src/oauth/util.go
Normal file
59
src/oauth/util.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package oauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getFormOrBasicAuth extracts client_id/client_secret from Basic Auth first, then form
|
||||||
|
func getFormOrBasicAuth(c *gin.Context) (clientID, clientSecret string) {
|
||||||
|
id, secret, ok := c.Request.BasicAuth()
|
||||||
|
if ok {
|
||||||
|
return strings.TrimSpace(id), strings.TrimSpace(secret)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(c.PostForm("client_id")), strings.TrimSpace(c.PostForm("client_secret"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// genCode generates URL-safe random string based on nBytes of entropy
|
||||||
|
func genCode(nBytes int) (string, error) {
|
||||||
|
b := make([]byte, nBytes)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// s256Base64URL computes base64url-encoded SHA256 digest
|
||||||
|
func s256Base64URL(verifier string) string {
|
||||||
|
sum := sha256.Sum256([]byte(verifier))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeNoStore sets no-store cache headers for OAuth responses
|
||||||
|
func writeNoStore(c *gin.Context) {
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
|
c.Header("Pragma", "no-cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeOAuthRedirectError builds an error redirect to redirect_uri as RFC6749
|
||||||
|
func writeOAuthRedirectError(c *gin.Context, redirectURI, errCode, description, state string) {
|
||||||
|
writeNoStore(c)
|
||||||
|
q := "error=" + url.QueryEscape(errCode)
|
||||||
|
if description != "" {
|
||||||
|
q += "&error_description=" + url.QueryEscape(description)
|
||||||
|
}
|
||||||
|
if state != "" {
|
||||||
|
q += "&state=" + url.QueryEscape(state)
|
||||||
|
}
|
||||||
|
sep := "?"
|
||||||
|
if strings.Contains(redirectURI, "?") {
|
||||||
|
sep = "&"
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, redirectURI+sep+q)
|
||||||
|
}
|
||||||
167
web/public/oauth-demo.html
Normal file
167
web/public/oauth-demo.html
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<!-- This file is a copy of examples/oauth-demo.html for direct serving under /oauth-demo.html -->
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>OAuth2/OIDC 授权码 + PKCE 前端演示</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg:#0b0c10; --panel:#111317; --muted:#aab2bf; --accent:#3b82f6; --ok:#16a34a; --warn:#f59e0b; --err:#ef4444; --border:#1f2430; }
|
||||||
|
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background: var(--bg); color:#e5e7eb; }
|
||||||
|
.wrap { max-width: 980px; margin: 32px auto; padding: 0 16px; }
|
||||||
|
h1 { font-size: 22px; margin:0 0 16px; }
|
||||||
|
.card { background: var(--panel); border:1px solid var(--border); border-radius: 10px; padding: 16px; margin: 12px 0; }
|
||||||
|
.row { display:flex; gap:12px; flex-wrap:wrap; }
|
||||||
|
.col { flex: 1 1 280px; display:flex; flex-direction:column; }
|
||||||
|
label { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
|
||||||
|
input, textarea, select { background:#0f1115; color:#e5e7eb; border:1px solid var(--border); padding:10px 12px; border-radius:8px; outline:none; }
|
||||||
|
textarea { min-height: 100px; resize: vertical; }
|
||||||
|
.btns { display:flex; gap:8px; flex-wrap:wrap; margin-top: 8px; }
|
||||||
|
button { background:#1a1f2b; color:#e5e7eb; border:1px solid var(--border); padding:8px 12px; border-radius:8px; cursor:pointer; }
|
||||||
|
button.primary { background: var(--accent); border-color: var(--accent); color:white; }
|
||||||
|
button.ok { background: var(--ok); border-color: var(--ok); color:white; }
|
||||||
|
button.warn { background: var(--warn); border-color: var(--warn); color:black; }
|
||||||
|
button.ghost { background: transparent; }
|
||||||
|
.muted { color: var(--muted); font-size: 12px; }
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||||
|
.grid2 { display:grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
@media (max-width: 880px){ .grid2 { grid-template-columns: 1fr; } }
|
||||||
|
.ok { color: #10b981; }
|
||||||
|
.err { color: #ef4444; }
|
||||||
|
.sep { height:1px; background: var(--border); margin: 12px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>OAuth2/OIDC 授权码 + PKCE 前端演示</h1>
|
||||||
|
<div class="card">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<label>Issuer(可选,用于自动发现 /.well-known/openid-configuration)</label>
|
||||||
|
<input id="issuer" placeholder="https://your-domain" />
|
||||||
|
<div class="btns"><button class="" id="btnDiscover">自动发现端点</button></div>
|
||||||
|
<div class="muted">提示:若未配置 Issuer,可直接填写下方端点。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<label>Response Type</label>
|
||||||
|
<select id="response_type">
|
||||||
|
<option value="code" selected>code</option>
|
||||||
|
<option value="token">token</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col"><label>Authorization Endpoint</label><input id="authorization_endpoint" placeholder="https://domain/api/oauth/authorize" /></div>
|
||||||
|
<div class="col"><label>Token Endpoint</label><input id="token_endpoint" placeholder="https://domain/api/oauth/token" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col"><label>UserInfo Endpoint(可选)</label><input id="userinfo_endpoint" placeholder="https://domain/api/oauth/userinfo" /></div>
|
||||||
|
<div class="col"><label>Client ID</label><input id="client_id" placeholder="your-public-client-id" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col"><label>Client Secret(可选,机密客户端)</label><input id="client_secret" placeholder="留空表示公开客户端" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col"><label>Redirect URI(当前页地址或你的回调)</label><input id="redirect_uri" /></div>
|
||||||
|
<div class="col"><label>Scope</label><input id="scope" value="openid profile email" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col"><label>State</label><input id="state" /></div>
|
||||||
|
<div class="col"><label>Nonce</label><input id="nonce" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col"><label>Code Verifier(自动生成,不会上送)</label><input id="code_verifier" class="mono" readonly /></div>
|
||||||
|
<div class="col"><label>Code Challenge(S256)</label><input id="code_challenge" class="mono" readonly /></div>
|
||||||
|
</div>
|
||||||
|
<div class="btns">
|
||||||
|
<button id="btnGenPkce">生成 PKCE</button>
|
||||||
|
<button id="btnRandomState">随机 State</button>
|
||||||
|
<button id="btnRandomNonce">随机 Nonce</button>
|
||||||
|
<button id="btnMakeAuthURL">生成授权链接</button>
|
||||||
|
<button id="btnAuthorize" class="primary">跳转授权</button>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:8px;">
|
||||||
|
<div class="col">
|
||||||
|
<label>授权链接(只生成不跳转)</label>
|
||||||
|
<textarea id="authorize_url" class="mono" placeholder="(空)"></textarea>
|
||||||
|
<div class="btns"><button id="btnCopyAuthURL">复制链接</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sep"></div>
|
||||||
|
<div class="muted">说明:
|
||||||
|
<ul>
|
||||||
|
<li>本页为纯前端演示,适用于公开客户端(不需要 client_secret)。</li>
|
||||||
|
<li>如跨域调用 Token/UserInfo,需要服务端正确设置 CORS;建议将此 demo 部署到同源域名下。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="sep"></div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<label>粘贴 OIDC Discovery JSON(/.well-known/openid-configuration)</label>
|
||||||
|
<textarea id="conf_json" class="mono" placeholder='{"issuer":"https://...","authorization_endpoint":"...","token_endpoint":"...","userinfo_endpoint":"..."}'></textarea>
|
||||||
|
<div class="btns">
|
||||||
|
<button id="btnParseConf">解析并填充端点</button>
|
||||||
|
<button id="btnGenConf">用当前端点生成 JSON</button>
|
||||||
|
</div>
|
||||||
|
<div class="muted">可将服务端返回的 OIDC Discovery JSON 粘贴到此处,点击“解析并填充端点”。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="row"><div class="col"><label>授权结果</label><div id="authResult" class="muted">等待授权...</div></div></div>
|
||||||
|
<div class="grid2" style="margin-top:12px;">
|
||||||
|
<div>
|
||||||
|
<label>Access Token</label>
|
||||||
|
<textarea id="access_token" class="mono" placeholder="(空)"></textarea>
|
||||||
|
<div class="btns"><button id="btnCopyAT">复制</button><button id="btnCallUserInfo" class="ok">调用 UserInfo</button></div>
|
||||||
|
<div id="userinfoOut" class="muted" style="margin-top:6px;"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>ID Token(JWT)</label>
|
||||||
|
<textarea id="id_token" class="mono" placeholder="(空)"></textarea>
|
||||||
|
<div class="btns"><button id="btnDecodeJWT">解码显示 Claims</button></div>
|
||||||
|
<pre id="jwtClaims" class="mono" style="white-space:pre-wrap; word-break:break-all; margin-top:6px;"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid2" style="margin-top:12px;">
|
||||||
|
<div>
|
||||||
|
<label>Refresh Token</label>
|
||||||
|
<textarea id="refresh_token" class="mono" placeholder="(空)"></textarea>
|
||||||
|
<div class="btns"><button id="btnRefreshToken">使用 Refresh Token 刷新</button></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>原始 Token 响应</label>
|
||||||
|
<textarea id="token_raw" class="mono" placeholder="(空)"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const toB64Url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
async function sha256B64Url(str){ const data=new TextEncoder().encode(str); const digest=await crypto.subtle.digest('SHA-256', data); return toB64Url(digest); }
|
||||||
|
function randStr(len=64){ const chars='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; const arr=new Uint8Array(len); crypto.getRandomValues(arr); return Array.from(arr,v=>chars[v%chars.length]).join(''); }
|
||||||
|
function setAuthInfo(msg, ok=true){ const el=$('authResult'); el.textContent=msg; el.className = ok? 'ok':'err'; }
|
||||||
|
function qs(name){ const u=new URL(location.href); return u.searchParams.get(name); }
|
||||||
|
function persist(k,v){ sessionStorage.setItem('demo_'+k, v); }
|
||||||
|
function load(k){ return sessionStorage.getItem('demo_'+k) || ''; }
|
||||||
|
(function init(){ $('redirect_uri').value = window.location.origin + window.location.pathname; const iss=load('issuer'); if(iss) $('issuer').value=iss; const cid=load('client_id'); if(cid) $('client_id').value=cid; const scp=load('scope'); if(scp) $('scope').value=scp; })();
|
||||||
|
$('btnDiscover').onclick = async () => { const iss=$('issuer').value.trim(); if(!iss){ alert('请填写 Issuer'); return; } try{ persist('issuer', iss); const res=await fetch(iss.replace(/\/$/,'')+'/api/.well-known/openid-configuration'); const d=await res.json(); $('authorization_endpoint').value=d.authorization_endpoint||''; $('token_endpoint').value=d.token_endpoint||''; $('userinfo_endpoint').value=d.userinfo_endpoint||''; if(d.issuer){ $('issuer').value=d.issuer; persist('issuer', d.issuer);} $('conf_json').value=JSON.stringify(d,null,2); setAuthInfo('已从发现文档加载端点', true);}catch(e){ setAuthInfo('自动发现失败:'+e,false);} };
|
||||||
|
$('btnGenPkce').onclick = async () => { const v=randStr(64); const c=await sha256B64Url(v); $('code_verifier').value=v; $('code_challenge').value=c; persist('code_verifier',v); persist('code_challenge',c); setAuthInfo('已生成 PKCE 参数', true); };
|
||||||
|
$('btnRandomState').onclick = ()=>{ $('state').value=randStr(16); persist('state',$('state').value);} ;
|
||||||
|
$('btnRandomNonce').onclick = ()=>{ $('nonce').value=randStr(16); persist('nonce',$('nonce').value);} ;
|
||||||
|
function buildAuthorizeURLFromFields(){ const auth=$('authorization_endpoint').value.trim(); const token=$('token_endpoint').value.trim(); const cid=$('client_id').value.trim(); const red=$('redirect_uri').value.trim(); const scp=$('scope').value.trim()||'openid profile email'; const rt=$('response_type').value; const st=$('state').value.trim()||randStr(16); const no=$('nonce').value.trim()||randStr(16); const cc=$('code_challenge').value.trim(); const cv=$('code_verifier').value.trim(); if(!auth||!cid||!red){ throw new Error('请先完善端点/ClientID/RedirectURI'); } if(rt==='code' && (!cc||!cv)){ throw new Error('请先生成 PKCE'); } persist('authorization_endpoint', auth); persist('token_endpoint', token); persist('client_id', cid); persist('redirect_uri', red); persist('scope', scp); persist('state', st); persist('nonce', no); persist('code_verifier', cv); const u=new URL(auth); u.searchParams.set('response_type', rt); u.searchParams.set('client_id',cid); u.searchParams.set('redirect_uri',red); u.searchParams.set('scope',scp); u.searchParams.set('state',st); if(no) u.searchParams.set('nonce',no); if(rt==='code'){ u.searchParams.set('code_challenge',cc); u.searchParams.set('code_challenge_method','S256'); } return u.toString(); }
|
||||||
|
$('btnMakeAuthURL').onclick = ()=>{ try{ const url=buildAuthorizeURLFromFields(); $('authorize_url').value=url; setAuthInfo('已生成授权链接',true);}catch(e){ setAuthInfo(e.message,false);} };
|
||||||
|
$('btnAuthorize').onclick = ()=>{ try{ const url=buildAuthorizeURLFromFields(); location.href=url; }catch(e){ setAuthInfo(e.message,false);} };
|
||||||
|
$('btnCopyAuthURL').onclick = async()=>{ try{ await navigator.clipboard.writeText($('authorize_url').value);}catch{} };
|
||||||
|
async function postForm(url, data, basic){ const body=Object.entries(data).map(([k,v])=> `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&'); const headers={ 'Content-Type':'application/x-www-form-urlencoded' }; if(basic&&basic.id&&basic.secret){ headers['Authorization']='Basic '+btoa(`${basic.id}:${basic.secret}`);} const res=await fetch(url,{ method:'POST', headers, body }); if(!res.ok){ const t=await res.text(); throw new Error(`HTTP ${res.status} ${t}`);} return res.json(); }
|
||||||
|
async function handleCallback(){ const frag=location.hash&&location.hash.startsWith('#')?new URLSearchParams(location.hash.slice(1)):null; const at=frag?frag.get('access_token'):null; const err=qs('error')|| (frag?frag.get('error'):null); const state=qs('state')||(frag?frag.get('state'):null); if(err){ setAuthInfo('授权失败:'+err,false); return; } if(at){ $('access_token').value=at||''; $('token_raw').value=JSON.stringify({ access_token: at, token_type: frag.get('token_type'), expires_in: frag.get('expires_in'), scope: frag.get('scope'), state }, null, 2); setAuthInfo('隐式模式已获取 Access Token', true); return; } const code=qs('code'); if(!code){ setAuthInfo('等待授权...', true); return; } if(state && load('state') && state!==load('state')){ setAuthInfo('state 不匹配,已拒绝', false); return; } try{ const tokenEp=load('token_endpoint'); const cid=load('client_id'); const csec=$('client_secret').value.trim(); const basic=csec?{id:cid,secret:csec}:null; const data=await postForm(tokenEp,{ grant_type:'authorization_code', code, client_id:cid, redirect_uri:load('redirect_uri'), code_verifier:load('code_verifier') }, basic); $('access_token').value=data.access_token||''; $('id_token').value=data.id_token||''; $('refresh_token').value=data.refresh_token||''; $('token_raw').value=JSON.stringify(data,null,2); setAuthInfo('授权成功,已获取令牌', true);}catch(e){ setAuthInfo('交换令牌失败:'+e.message,false);} } handleCallback();
|
||||||
|
$('btnCopyAT').onclick = async ()=>{ try{ await navigator.clipboard.writeText($('access_token').value);}catch{} };
|
||||||
|
$('btnDecodeJWT').onclick = ()=>{ const t=$('id_token').value.trim(); if(!t){ $('jwtClaims').textContent='(空)'; return;} const parts=t.split('.'); if(parts.length<2){ $('jwtClaims').textContent='格式错误'; return;} try{ const json=JSON.parse(atob(parts[1].replace(/-/g,'+').replace(/_/g,'/'))); $('jwtClaims').textContent=JSON.stringify(json,null,2);}catch(e){ $('jwtClaims').textContent='解码失败:'+e; } };
|
||||||
|
$('btnCallUserInfo').onclick = async ()=>{ const at=$('access_token').value.trim(); const ep=$('userinfo_endpoint').value.trim(); if(!at||!ep){ alert('请填写UserInfo端点并获取AccessToken'); return;} try{ const res=await fetch(ep,{ headers:{ Authorization:'Bearer '+at }}); const data=await res.json(); $('userinfoOut').textContent=JSON.stringify(data,null,2);}catch(e){ $('userinfoOut').textContent='调用失败:'+e; } };
|
||||||
|
$('btnRefreshToken').onclick = async ()=>{ const rt=$('refresh_token').value.trim(); if(!rt){ alert('没有刷新令牌'); return;} try{ const tokenEp=load('token_endpoint'); const cid=load('client_id'); const csec=$('client_secret').value.trim(); const basic=csec?{id:cid,secret:csec}:null; const data=await postForm(tokenEp,{ grant_type:'refresh_token', refresh_token:rt, client_id:cid }, basic); $('access_token').value=data.access_token||''; $('id_token').value=data.id_token||''; $('refresh_token').value=data.refresh_token||''; $('token_raw').value=JSON.stringify(data,null,2); setAuthInfo('刷新成功', true);}catch(e){ setAuthInfo('刷新失败:'+e.message,false);} };
|
||||||
|
$('btnParseConf').onclick = ()=>{ const txt=$('conf_json').value.trim(); if(!txt){ alert('请先粘贴 JSON'); return;} try{ const d=JSON.parse(txt); if(d.issuer){ $('issuer').value=d.issuer; persist('issuer', d.issuer);} if(d.authorization_endpoint) $('authorization_endpoint').value=d.authorization_endpoint; if(d.token_endpoint) $('token_endpoint').value=d.token_endpoint; if(d.userinfo_endpoint) $('userinfo_endpoint').value=d.userinfo_endpoint; setAuthInfo('已解析配置并填充端点', true);}catch(e){ setAuthInfo('解析失败:'+e,false);} };
|
||||||
|
$('btnGenConf').onclick = ()=>{ const d={ issuer:$('issuer').value.trim()||undefined, authorization_endpoint:$('authorization_endpoint').value.trim()||undefined, token_endpoint:$('token_endpoint').value.trim()||undefined, userinfo_endpoint:$('userinfo_endpoint').value.trim()||undefined }; $('conf_json').value = JSON.stringify(d,null,2); };
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -44,6 +44,7 @@ import Task from './pages/Task';
|
|||||||
import ModelPage from './pages/Model';
|
import ModelPage from './pages/Model';
|
||||||
import Playground from './pages/Playground';
|
import Playground from './pages/Playground';
|
||||||
import OAuth2Callback from './components/auth/OAuth2Callback';
|
import OAuth2Callback from './components/auth/OAuth2Callback';
|
||||||
|
import OAuthConsent from './pages/OAuth/Consent';
|
||||||
import PersonalSetting from './components/settings/PersonalSetting';
|
import PersonalSetting from './components/settings/PersonalSetting';
|
||||||
import Setup from './pages/Setup';
|
import Setup from './pages/Setup';
|
||||||
import SetupCheck from './components/layout/SetupCheck';
|
import SetupCheck from './components/layout/SetupCheck';
|
||||||
@@ -198,6 +199,14 @@ function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path='/oauth/consent'
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<Loading></Loading>}>
|
||||||
|
<OAuthConsent />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/oauth/linuxdo'
|
path='/oauth/linuxdo'
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
Form,
|
Form,
|
||||||
@@ -40,17 +40,128 @@ const { Option } = Select;
|
|||||||
const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||||
const [formApi, setFormApi] = useState(null);
|
const [formApi, setFormApi] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [redirectUris, setRedirectUris] = useState(['']);
|
const [redirectUris, setRedirectUris] = useState([]);
|
||||||
const [clientType, setClientType] = useState('confidential');
|
const [clientType, setClientType] = useState('confidential');
|
||||||
const [grantTypes, setGrantTypes] = useState(['client_credentials']);
|
const [grantTypes, setGrantTypes] = useState(['client_credentials']);
|
||||||
|
const [allowedGrantTypes, setAllowedGrantTypes] = useState([
|
||||||
|
'client_credentials',
|
||||||
|
'authorization_code',
|
||||||
|
'refresh_token',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 加载后端允许的授权类型(用于限制和默认值)
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await API.get('/api/option/');
|
||||||
|
const { success, data } = res.data || {};
|
||||||
|
if (!success || !Array.isArray(data)) return;
|
||||||
|
const found = data.find((i) => i.key === 'oauth2.allowed_grant_types');
|
||||||
|
if (!found) return;
|
||||||
|
let parsed = [];
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(found.value || '[]');
|
||||||
|
} catch (_) {}
|
||||||
|
if (mounted && Array.isArray(parsed) && parsed.length) {
|
||||||
|
setAllowedGrantTypes(parsed);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 忽略错误,使用默认allowedGrantTypes
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const computeDefaultGrantTypes = (type, allowed) => {
|
||||||
|
const cand =
|
||||||
|
type === 'public'
|
||||||
|
? ['authorization_code', 'refresh_token']
|
||||||
|
: ['client_credentials', 'authorization_code', 'refresh_token'];
|
||||||
|
const subset = cand.filter((g) => allowed.includes(g));
|
||||||
|
return subset.length ? subset : [allowed[0]].filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当允许的类型或客户端类型变化时,自动设置更合理的默认值
|
||||||
|
useEffect(() => {
|
||||||
|
setGrantTypes((prev) => {
|
||||||
|
const normalizedPrev = Array.isArray(prev) ? prev : [];
|
||||||
|
// 移除不被允许或与客户端类型冲突的类型
|
||||||
|
let next = normalizedPrev.filter((g) => allowedGrantTypes.includes(g));
|
||||||
|
if (clientType === 'public') {
|
||||||
|
next = next.filter((g) => g !== 'client_credentials');
|
||||||
|
}
|
||||||
|
// 如果为空,则使用计算的默认
|
||||||
|
if (!next.length) {
|
||||||
|
next = computeDefaultGrantTypes(clientType, allowedGrantTypes);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [clientType, allowedGrantTypes]);
|
||||||
|
|
||||||
|
const isGrantTypeDisabled = (value) => {
|
||||||
|
if (!allowedGrantTypes.includes(value)) return true;
|
||||||
|
if (clientType === 'public' && value === 'client_credentials') return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// URL校验:允许 http(s),本地开发可 http
|
||||||
|
const isValidRedirectUri = (uri) => {
|
||||||
|
if (!uri || !uri.trim()) return false;
|
||||||
|
try {
|
||||||
|
const u = new URL(uri.trim());
|
||||||
|
if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;
|
||||||
|
if (u.protocol === 'http:') {
|
||||||
|
// 仅允许本地开发时使用 http
|
||||||
|
const host = u.hostname;
|
||||||
|
const isLocal =
|
||||||
|
host === 'localhost' || host === '127.0.0.1' || host.endsWith('.local');
|
||||||
|
if (!isLocal) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 处理提交
|
// 处理提交
|
||||||
const handleSubmit = async (values) => {
|
const handleSubmit = async (values) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// 过滤空的重定向URI
|
// 过滤空的重定向URI
|
||||||
const validRedirectUris = redirectUris.filter(uri => uri.trim());
|
const validRedirectUris = redirectUris
|
||||||
|
.map((u) => (u || '').trim())
|
||||||
|
.filter((u) => u.length > 0);
|
||||||
|
|
||||||
|
// 业务校验
|
||||||
|
if (!grantTypes.length) {
|
||||||
|
showError('请至少选择一种授权类型');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 校验是否包含不被允许的授权类型
|
||||||
|
const invalids = grantTypes.filter((g) => !allowedGrantTypes.includes(g));
|
||||||
|
if (invalids.length) {
|
||||||
|
showError(`不被允许的授权类型: ${invalids.join(', ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (clientType === 'public' && grantTypes.includes('client_credentials')) {
|
||||||
|
showError('公开客户端不允许使用client_credentials授权类型');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (grantTypes.includes('authorization_code')) {
|
||||||
|
if (!validRedirectUris.length) {
|
||||||
|
showError('选择授权码授权类型时,必须填写至少一个重定向URI');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const allValid = validRedirectUris.every(isValidRedirectUri);
|
||||||
|
if (!allValid) {
|
||||||
|
showError('重定向URI格式不合法:仅支持https,或本地开发使用http');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
client_type: clientType,
|
client_type: clientType,
|
||||||
@@ -118,8 +229,8 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
|||||||
formApi.reset();
|
formApi.reset();
|
||||||
}
|
}
|
||||||
setClientType('confidential');
|
setClientType('confidential');
|
||||||
setGrantTypes(['client_credentials']);
|
setGrantTypes(computeDefaultGrantTypes('confidential', allowedGrantTypes));
|
||||||
setRedirectUris(['']);
|
setRedirectUris([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理取消
|
// 处理取消
|
||||||
@@ -149,9 +260,13 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
|||||||
const handleGrantTypesChange = (values) => {
|
const handleGrantTypesChange = (values) => {
|
||||||
setGrantTypes(values);
|
setGrantTypes(values);
|
||||||
// 如果包含authorization_code但没有重定向URI,则添加一个
|
// 如果包含authorization_code但没有重定向URI,则添加一个
|
||||||
if (values.includes('authorization_code') && redirectUris.length === 1 && !redirectUris[0]) {
|
if (values.includes('authorization_code') && redirectUris.length === 0) {
|
||||||
setRedirectUris(['']);
|
setRedirectUris(['']);
|
||||||
}
|
}
|
||||||
|
// 公开客户端不允许client_credentials
|
||||||
|
if (clientType === 'public' && values.includes('client_credentials')) {
|
||||||
|
setGrantTypes(values.filter((v) => v !== 'client_credentials'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -159,7 +274,7 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
|||||||
title="创建OAuth2客户端"
|
title="创建OAuth2客户端"
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onOk={() => formApi?.submit()}
|
onOk={() => formApi?.submitForm()}
|
||||||
okText="创建"
|
okText="创建"
|
||||||
cancelText="取消"
|
cancelText="取消"
|
||||||
confirmLoading={loading}
|
confirmLoading={loading}
|
||||||
@@ -168,6 +283,12 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
|||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
getFormApi={(api) => setFormApi(api)}
|
getFormApi={(api) => setFormApi(api)}
|
||||||
|
initValues={{
|
||||||
|
// 表单默认值优化:预置 OIDC 常用 scope
|
||||||
|
scopes: ['openid', 'profile', 'email', 'api:read'],
|
||||||
|
require_pkce: true,
|
||||||
|
grant_types: grantTypes,
|
||||||
|
}}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
labelPosition="top"
|
labelPosition="top"
|
||||||
>
|
>
|
||||||
@@ -237,9 +358,15 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
|||||||
onChange={handleGrantTypesChange}
|
onChange={handleGrantTypesChange}
|
||||||
rules={[{ required: true, message: '请选择至少一种授权类型' }]}
|
rules={[{ required: true, message: '请选择至少一种授权类型' }]}
|
||||||
>
|
>
|
||||||
<Option value="client_credentials">Client Credentials(客户端凭证)</Option>
|
<Option value="client_credentials" disabled={isGrantTypeDisabled('client_credentials')}>
|
||||||
<Option value="authorization_code">Authorization Code(授权码)</Option>
|
Client Credentials(客户端凭证)
|
||||||
<Option value="refresh_token">Refresh Token(刷新令牌)</Option>
|
</Option>
|
||||||
|
<Option value="authorization_code" disabled={isGrantTypeDisabled('authorization_code')}>
|
||||||
|
Authorization Code(授权码)
|
||||||
|
</Option>
|
||||||
|
<Option value="refresh_token" disabled={isGrantTypeDisabled('refresh_token')}>
|
||||||
|
Refresh Token(刷新令牌)
|
||||||
|
</Option>
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
|
|
||||||
{/* Scope */}
|
{/* Scope */}
|
||||||
@@ -247,9 +374,11 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
|||||||
field="scopes"
|
field="scopes"
|
||||||
label="允许的权限范围(Scope)"
|
label="允许的权限范围(Scope)"
|
||||||
multiple
|
multiple
|
||||||
defaultValue={['api:read']}
|
|
||||||
rules={[{ required: true, message: '请选择至少一个权限范围' }]}
|
rules={[{ required: true, message: '请选择至少一个权限范围' }]}
|
||||||
>
|
>
|
||||||
|
<Option value="openid">openid(OIDC 基础身份)</Option>
|
||||||
|
<Option value="profile">profile(用户名/昵称等)</Option>
|
||||||
|
<Option value="email">email(邮箱信息)</Option>
|
||||||
<Option value="api:read">api:read(读取API)</Option>
|
<Option value="api:read">api:read(读取API)</Option>
|
||||||
<Option value="api:write">api:write(写入API)</Option>
|
<Option value="api:write">api:write(写入API)</Option>
|
||||||
<Option value="admin">admin(管理员权限)</Option>
|
<Option value="admin">admin(管理员权限)</Option>
|
||||||
@@ -259,20 +388,19 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
|||||||
<Form.Switch
|
<Form.Switch
|
||||||
field="require_pkce"
|
field="require_pkce"
|
||||||
label="强制PKCE验证"
|
label="强制PKCE验证"
|
||||||
defaultChecked={true}
|
|
||||||
/>
|
/>
|
||||||
<Paragraph type="tertiary" size="small" style={{ marginTop: -8, marginBottom: 16 }}>
|
<Paragraph type="tertiary" size="small" style={{ marginTop: -8, marginBottom: 16 }}>
|
||||||
PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。
|
PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
|
|
||||||
{/* 重定向URI */}
|
{/* 重定向URI */}
|
||||||
{grantTypes.includes('authorization_code') && (
|
{(grantTypes.includes('authorization_code') || redirectUris.length > 0) && (
|
||||||
<>
|
<>
|
||||||
<Divider>重定向URI配置</Divider>
|
<Divider>重定向URI配置</Divider>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<Text strong>重定向URI</Text>
|
<Text strong>重定向URI</Text>
|
||||||
<Paragraph type="tertiary" size="small">
|
<Paragraph type="tertiary" size="small">
|
||||||
用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP)。
|
用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
@@ -315,4 +443,4 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CreateOAuth2ClientModal;
|
export default CreateOAuth2ClientModal;
|
||||||
|
|||||||
@@ -39,8 +39,39 @@ const { Option } = Select;
|
|||||||
const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
||||||
const [formApi, setFormApi] = useState(null);
|
const [formApi, setFormApi] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [redirectUris, setRedirectUris] = useState(['']);
|
const [redirectUris, setRedirectUris] = useState([]);
|
||||||
const [grantTypes, setGrantTypes] = useState(['client_credentials']);
|
const [grantTypes, setGrantTypes] = useState(['client_credentials']);
|
||||||
|
const [allowedGrantTypes, setAllowedGrantTypes] = useState([
|
||||||
|
'client_credentials',
|
||||||
|
'authorization_code',
|
||||||
|
'refresh_token',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 加载后端允许的授权类型
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await API.get('/api/option/');
|
||||||
|
const { success, data } = res.data || {};
|
||||||
|
if (!success || !Array.isArray(data)) return;
|
||||||
|
const found = data.find((i) => i.key === 'oauth2.allowed_grant_types');
|
||||||
|
if (!found) return;
|
||||||
|
let parsed = [];
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(found.value || '[]');
|
||||||
|
} catch (_) {}
|
||||||
|
if (mounted && Array.isArray(parsed) && parsed.length) {
|
||||||
|
setAllowedGrantTypes(parsed);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 初始化表单数据
|
// 初始化表单数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -60,9 +91,12 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
|||||||
} else if (Array.isArray(client.scopes)) {
|
} else if (Array.isArray(client.scopes)) {
|
||||||
parsedScopes = client.scopes;
|
parsedScopes = client.scopes;
|
||||||
}
|
}
|
||||||
|
if (!parsedScopes || parsedScopes.length === 0) {
|
||||||
|
parsedScopes = ['openid', 'profile', 'email', 'api:read'];
|
||||||
|
}
|
||||||
|
|
||||||
// 解析重定向URI
|
// 解析重定向URI
|
||||||
let parsedRedirectUris = [''];
|
let parsedRedirectUris = [];
|
||||||
if (client.redirect_uris) {
|
if (client.redirect_uris) {
|
||||||
try {
|
try {
|
||||||
const parsed = typeof client.redirect_uris === 'string'
|
const parsed = typeof client.redirect_uris === 'string'
|
||||||
@@ -76,8 +110,20 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setGrantTypes(parsedGrantTypes);
|
// 过滤不被允许或不兼容的授权类型
|
||||||
setRedirectUris(parsedRedirectUris);
|
const filteredGrantTypes = (parsedGrantTypes || []).filter((g) =>
|
||||||
|
allowedGrantTypes.includes(g),
|
||||||
|
);
|
||||||
|
const finalGrantTypes = client.client_type === 'public'
|
||||||
|
? filteredGrantTypes.filter((g) => g !== 'client_credentials')
|
||||||
|
: filteredGrantTypes;
|
||||||
|
|
||||||
|
setGrantTypes(finalGrantTypes);
|
||||||
|
if (finalGrantTypes.includes('authorization_code') && parsedRedirectUris.length === 0) {
|
||||||
|
setRedirectUris(['']);
|
||||||
|
} else {
|
||||||
|
setRedirectUris(parsedRedirectUris);
|
||||||
|
}
|
||||||
|
|
||||||
// 设置表单值
|
// 设置表单值
|
||||||
const formValues = {
|
const formValues = {
|
||||||
@@ -87,7 +133,7 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
|||||||
client_type: client.client_type,
|
client_type: client.client_type,
|
||||||
grant_types: parsedGrantTypes,
|
grant_types: parsedGrantTypes,
|
||||||
scopes: parsedScopes,
|
scopes: parsedScopes,
|
||||||
require_pkce: client.require_pkce,
|
require_pkce: !!client.require_pkce,
|
||||||
status: client.status,
|
status: client.status,
|
||||||
};
|
};
|
||||||
if (formApi) {
|
if (formApi) {
|
||||||
@@ -101,7 +147,57 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// 过滤空的重定向URI
|
// 过滤空的重定向URI
|
||||||
const validRedirectUris = redirectUris.filter(uri => uri.trim());
|
const validRedirectUris = redirectUris
|
||||||
|
.map((u) => (u || '').trim())
|
||||||
|
.filter((u) => u.length > 0);
|
||||||
|
|
||||||
|
// 校验授权类型
|
||||||
|
if (!grantTypes.length) {
|
||||||
|
showError('请至少选择一种授权类型');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const invalids = grantTypes.filter((g) => !allowedGrantTypes.includes(g));
|
||||||
|
if (invalids.length) {
|
||||||
|
showError(`不被允许的授权类型: ${invalids.join(', ')}`);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (client?.client_type === 'public' && grantTypes.includes('client_credentials')) {
|
||||||
|
showError('公开客户端不允许使用client_credentials授权类型');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 授权码需要有效重定向URI
|
||||||
|
const isValidRedirectUri = (uri) => {
|
||||||
|
if (!uri || !uri.trim()) return false;
|
||||||
|
try {
|
||||||
|
const u = new URL(uri.trim());
|
||||||
|
if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;
|
||||||
|
if (u.protocol === 'http:') {
|
||||||
|
const host = u.hostname;
|
||||||
|
const isLocal =
|
||||||
|
host === 'localhost' || host === '127.0.0.1' || host.endsWith('.local');
|
||||||
|
if (!isLocal) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (grantTypes.includes('authorization_code')) {
|
||||||
|
if (!validRedirectUris.length) {
|
||||||
|
showError('选择授权码授权类型时,必须填写至少一个重定向URI');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const allValid = validRedirectUris.every(isValidRedirectUri);
|
||||||
|
if (!allValid) {
|
||||||
|
showError('重定向URI格式不合法:仅支持https,或本地开发使用http');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
@@ -146,9 +242,13 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
|||||||
const handleGrantTypesChange = (values) => {
|
const handleGrantTypesChange = (values) => {
|
||||||
setGrantTypes(values);
|
setGrantTypes(values);
|
||||||
// 如果包含authorization_code但没有重定向URI,则添加一个
|
// 如果包含authorization_code但没有重定向URI,则添加一个
|
||||||
if (values.includes('authorization_code') && redirectUris.length === 1 && !redirectUris[0]) {
|
if (values.includes('authorization_code') && redirectUris.length === 0) {
|
||||||
setRedirectUris(['']);
|
setRedirectUris(['']);
|
||||||
}
|
}
|
||||||
|
// 公开客户端不允许client_credentials
|
||||||
|
if (client?.client_type === 'public' && values.includes('client_credentials')) {
|
||||||
|
setGrantTypes(values.filter((v) => v !== 'client_credentials'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!client) return null;
|
if (!client) return null;
|
||||||
@@ -158,7 +258,7 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
|||||||
title={`编辑OAuth2客户端 - ${client.name}`}
|
title={`编辑OAuth2客户端 - ${client.name}`}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onOk={() => formApi?.submit()}
|
onOk={() => formApi?.submitForm()}
|
||||||
okText="保存"
|
okText="保存"
|
||||||
cancelText="取消"
|
cancelText="取消"
|
||||||
confirmLoading={loading}
|
confirmLoading={loading}
|
||||||
@@ -217,9 +317,17 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
|||||||
onChange={handleGrantTypesChange}
|
onChange={handleGrantTypesChange}
|
||||||
rules={[{ required: true, message: '请选择至少一种授权类型' }]}
|
rules={[{ required: true, message: '请选择至少一种授权类型' }]}
|
||||||
>
|
>
|
||||||
<Option value="client_credentials">Client Credentials(客户端凭证)</Option>
|
<Option value="client_credentials" disabled={
|
||||||
<Option value="authorization_code">Authorization Code(授权码)</Option>
|
client?.client_type === 'public' || !allowedGrantTypes.includes('client_credentials')
|
||||||
<Option value="refresh_token">Refresh Token(刷新令牌)</Option>
|
}>
|
||||||
|
Client Credentials(客户端凭证)
|
||||||
|
</Option>
|
||||||
|
<Option value="authorization_code" disabled={!allowedGrantTypes.includes('authorization_code')}>
|
||||||
|
Authorization Code(授权码)
|
||||||
|
</Option>
|
||||||
|
<Option value="refresh_token" disabled={!allowedGrantTypes.includes('refresh_token')}>
|
||||||
|
Refresh Token(刷新令牌)
|
||||||
|
</Option>
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
|
|
||||||
{/* Scope */}
|
{/* Scope */}
|
||||||
@@ -229,6 +337,9 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
|||||||
multiple
|
multiple
|
||||||
rules={[{ required: true, message: '请选择至少一个权限范围' }]}
|
rules={[{ required: true, message: '请选择至少一个权限范围' }]}
|
||||||
>
|
>
|
||||||
|
<Option value="openid">openid(OIDC 基础身份)</Option>
|
||||||
|
<Option value="profile">profile(用户名/昵称等)</Option>
|
||||||
|
<Option value="email">email(邮箱信息)</Option>
|
||||||
<Option value="api:read">api:read(读取API)</Option>
|
<Option value="api:read">api:read(读取API)</Option>
|
||||||
<Option value="api:write">api:write(写入API)</Option>
|
<Option value="api:write">api:write(写入API)</Option>
|
||||||
<Option value="admin">admin(管理员权限)</Option>
|
<Option value="admin">admin(管理员权限)</Option>
|
||||||
@@ -254,13 +365,13 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
|||||||
</Form.Select>
|
</Form.Select>
|
||||||
|
|
||||||
{/* 重定向URI */}
|
{/* 重定向URI */}
|
||||||
{grantTypes.includes('authorization_code') && (
|
{(grantTypes.includes('authorization_code') || redirectUris.length > 0) && (
|
||||||
<>
|
<>
|
||||||
<Divider>重定向URI配置</Divider>
|
<Divider>重定向URI配置</Divider>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<Text strong>重定向URI</Text>
|
<Text strong>重定向URI</Text>
|
||||||
<Paragraph type="tertiary" size="small">
|
<Paragraph type="tertiary" size="small">
|
||||||
用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP)。
|
用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
@@ -303,4 +414,4 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EditOAuth2ClientModal;
|
export default EditOAuth2ClientModal;
|
||||||
|
|||||||
148
web/src/components/modals/oauth2/JWKSManagerModal.jsx
Normal file
148
web/src/components/modals/oauth2/JWKSManagerModal.jsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Modal, Table, Button, Space, Tag, Typography, Popconfirm, Toast, Form, TextArea, Divider, Input } from '@douyinfe/semi-ui';
|
||||||
|
import { IconRefresh, IconDelete, IconPlay } from '@douyinfe/semi-icons';
|
||||||
|
import { API, showError, showSuccess } from '../../../helpers';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export default function JWKSManagerModal({ visible, onClose }) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [keys, setKeys] = useState([]);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.get('/api/oauth/keys');
|
||||||
|
if (res?.data?.success) setKeys(res.data.data || []);
|
||||||
|
else showError(res?.data?.message || '获取密钥列表失败');
|
||||||
|
} catch { showError('获取密钥列表失败'); } finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotate = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/oauth/keys/rotate', {});
|
||||||
|
if (res?.data?.success) { showSuccess('签名密钥已轮换:' + res.data.kid); await load(); }
|
||||||
|
else showError(res?.data?.message || '密钥轮换失败');
|
||||||
|
} catch { showError('密钥轮换失败'); } finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const del = async (kid) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.delete(`/api/oauth/keys/${kid}`);
|
||||||
|
if (res?.data?.success) { Toast.success('已删除:' + kid); await load(); }
|
||||||
|
else showError(res?.data?.message || '删除失败');
|
||||||
|
} catch { showError('删除失败'); } finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { if (visible) load(); }, [visible]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
(async ()=>{
|
||||||
|
try{
|
||||||
|
const res = await API.get('/api/oauth/server-info');
|
||||||
|
const p = res?.data?.default_private_key_path;
|
||||||
|
if (p) setGenPath(p);
|
||||||
|
}catch{}
|
||||||
|
})();
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// Import PEM state
|
||||||
|
const [showImport, setShowImport] = useState(false);
|
||||||
|
const [pem, setPem] = useState('');
|
||||||
|
const [customKid, setCustomKid] = useState('');
|
||||||
|
const importPem = async () => {
|
||||||
|
if (!pem.trim()) return Toast.warning('请粘贴 PEM 私钥');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/oauth/keys/import_pem', { pem, kid: customKid.trim() });
|
||||||
|
if (res?.data?.success) {
|
||||||
|
Toast.success('已导入私钥并切换到 kid=' + res.data.kid);
|
||||||
|
setPem(''); setCustomKid(''); setShowImport(false);
|
||||||
|
await load();
|
||||||
|
} else {
|
||||||
|
Toast.error(res?.data?.message || '导入失败');
|
||||||
|
}
|
||||||
|
} catch { Toast.error('导入失败'); } finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate PEM file state
|
||||||
|
const [showGenerate, setShowGenerate] = useState(false);
|
||||||
|
const [genPath, setGenPath] = useState('/etc/new-api/oauth2-private.pem');
|
||||||
|
const [genKid, setGenKid] = useState('');
|
||||||
|
const generatePemFile = async () => {
|
||||||
|
if (!genPath.trim()) return Toast.warning('请填写保存路径');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/oauth/keys/generate_file', { path: genPath.trim(), kid: genKid.trim() });
|
||||||
|
if (res?.data?.success) {
|
||||||
|
Toast.success('已生成并生效:' + res.data.path);
|
||||||
|
await load();
|
||||||
|
} else {
|
||||||
|
Toast.error(res?.data?.message || '生成失败');
|
||||||
|
}
|
||||||
|
} catch { Toast.error('生成失败'); } finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: 'KID', dataIndex: 'kid', render: (kid) => <Text code copyable>{kid}</Text> },
|
||||||
|
{ title: '创建时间', dataIndex: 'created_at', render: (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '-') },
|
||||||
|
{ title: '状态', dataIndex: 'current', render: (cur) => (cur ? <Tag color='green'>当前</Tag> : <Tag>历史</Tag>) },
|
||||||
|
{ title: '操作', render: (_, r) => (
|
||||||
|
<Space>
|
||||||
|
{!r.current && (
|
||||||
|
<Popconfirm title={`确定删除密钥 ${r.kid} ?`} content='删除后使用该 kid 签发的旧令牌仍可被验证(外部 JWKS 缓存可能仍保留)' okText='删除' onConfirm={() => del(r.kid)}>
|
||||||
|
<Button icon={<IconDelete />} size='small' theme='borderless'>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
title='JWKS 管理'
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={820}
|
||||||
|
style={{ top: 48 }}
|
||||||
|
>
|
||||||
|
<Space style={{ marginBottom: 8 }}>
|
||||||
|
<Button icon={<IconRefresh />} onClick={load} loading={loading}>刷新</Button>
|
||||||
|
<Button icon={<IconPlay />} type='primary' onClick={rotate} loading={loading}>轮换密钥</Button>
|
||||||
|
<Button onClick={()=>setShowImport(!showImport)}>导入 PEM 私钥</Button>
|
||||||
|
<Button onClick={()=>setShowGenerate(!showGenerate)}>生成 PEM 文件</Button>
|
||||||
|
<Button onClick={onClose}>关闭</Button>
|
||||||
|
</Space>
|
||||||
|
{showGenerate && (
|
||||||
|
<div style={{ border: '1px solid var(--semi-color-border)', borderRadius: 6, padding: 12, marginBottom: 12 }}>
|
||||||
|
<Form labelPosition='left' labelWidth={120}>
|
||||||
|
<Form.Input field='path' label='保存路径' value={genPath} onChange={setGenPath} placeholder='/secure/path/oauth2-private.pem' />
|
||||||
|
<Form.Input field='genKid' label='自定义 KID' value={genKid} onChange={setGenKid} placeholder='可留空自动生成' />
|
||||||
|
</Form>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Button type='primary' onClick={generatePemFile} loading={loading}>生成并生效</Button>
|
||||||
|
</div>
|
||||||
|
<Divider margin='12px' />
|
||||||
|
<Text type='tertiary'>建议:仅在合规要求下使用文件私钥。请确保目录权限安全(建议 0600),并妥善备份。</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showImport && (
|
||||||
|
<div style={{ border: '1px solid var(--semi-color-border)', borderRadius: 6, padding: 12, marginBottom: 12 }}>
|
||||||
|
<Form labelPosition='left' labelWidth={120}>
|
||||||
|
<Form.Input field='kid' label='自定义 KID' placeholder='可留空自动生成' value={customKid} onChange={setCustomKid} />
|
||||||
|
<Form.TextArea field='pem' label='PEM 私钥' value={pem} onChange={setPem} rows={6} placeholder={'-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----'} />
|
||||||
|
</Form>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Button type='primary' onClick={importPem} loading={loading}>导入并生效</Button>
|
||||||
|
</div>
|
||||||
|
<Divider margin='12px' />
|
||||||
|
<Text type='tertiary'>建议:优先使用内存签名密钥与 JWKS 轮换;仅在有合规要求时导入外部私钥。</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Table dataSource={keys} columns={columns} rowKey='kid' loading={loading} pagination={false} empty={<Text type='tertiary'>暂无密钥</Text>} />
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
web/src/components/modals/oauth2/OAuth2QuickStartModal.jsx
Normal file
230
web/src/components/modals/oauth2/OAuth2QuickStartModal.jsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Modal, Steps, Form, Input, Select, Switch, Typography, Space, Button, Tag, Toast } from '@douyinfe/semi-ui';
|
||||||
|
import { API, showError, showSuccess } from '../../../helpers';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export default function OAuth2QuickStartModal({ visible, onClose, onDone }) {
|
||||||
|
const origin = useMemo(() => window.location.origin, []);
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Step state
|
||||||
|
const [enableOAuth, setEnableOAuth] = useState(true);
|
||||||
|
const [issuer, setIssuer] = useState(origin);
|
||||||
|
|
||||||
|
const [clientType, setClientType] = useState('public');
|
||||||
|
const [redirect1, setRedirect1] = useState(origin + '/oauth/oidc');
|
||||||
|
const [redirect2, setRedirect2] = useState('');
|
||||||
|
const [scopes, setScopes] = useState(['openid', 'profile', 'email', 'api:read']);
|
||||||
|
|
||||||
|
// Results
|
||||||
|
const [createdClient, setCreatedClient] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setStep(0);
|
||||||
|
setLoading(false);
|
||||||
|
setEnableOAuth(true);
|
||||||
|
setIssuer(origin);
|
||||||
|
setClientType('public');
|
||||||
|
setRedirect1(origin + '/oauth/oidc');
|
||||||
|
setRedirect2('');
|
||||||
|
setScopes(['openid', 'profile', 'email', 'api:read']);
|
||||||
|
setCreatedClient(null);
|
||||||
|
}
|
||||||
|
}, [visible, origin]);
|
||||||
|
|
||||||
|
// 打开时读取现有配置作为默认值
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await API.get('/api/option/');
|
||||||
|
const { success, data } = res.data || {};
|
||||||
|
if (!success || !Array.isArray(data)) return;
|
||||||
|
const map = Object.fromEntries(data.map(i => [i.key, i.value]));
|
||||||
|
if (typeof map['oauth2.enabled'] !== 'undefined') {
|
||||||
|
setEnableOAuth(String(map['oauth2.enabled']).toLowerCase() === 'true');
|
||||||
|
}
|
||||||
|
if (map['oauth2.issuer']) {
|
||||||
|
setIssuer(map['oauth2.issuer']);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const applyRecommended = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const ops = [
|
||||||
|
{ key: 'oauth2.enabled', value: String(enableOAuth) },
|
||||||
|
{ key: 'oauth2.issuer', value: issuer || '' },
|
||||||
|
{ key: 'oauth2.allowed_grant_types', value: JSON.stringify(['authorization_code', 'refresh_token', 'client_credentials']) },
|
||||||
|
{ key: 'oauth2.require_pkce', value: 'true' },
|
||||||
|
{ key: 'oauth2.jwt_signing_algorithm', value: 'RS256' },
|
||||||
|
];
|
||||||
|
for (const op of ops) {
|
||||||
|
await API.put('/api/option/', op);
|
||||||
|
}
|
||||||
|
showSuccess('已应用推荐配置');
|
||||||
|
setStep(1);
|
||||||
|
onDone && onDone();
|
||||||
|
} catch (e) {
|
||||||
|
showError('应用推荐配置失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotateKey = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/oauth/keys/rotate', {});
|
||||||
|
if (res?.data?.success) {
|
||||||
|
showSuccess('签名密钥已准备:' + res.data.kid);
|
||||||
|
} else {
|
||||||
|
showError(res?.data?.message || '签名密钥操作失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStep(2);
|
||||||
|
} catch (e) {
|
||||||
|
showError('签名密钥操作失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createClient = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const grant_types = clientType === 'public' ? ['authorization_code', 'refresh_token'] : ['authorization_code', 'refresh_token', 'client_credentials'];
|
||||||
|
const payload = {
|
||||||
|
name: 'Default OIDC Client',
|
||||||
|
client_type: clientType,
|
||||||
|
grant_types,
|
||||||
|
redirect_uris: [redirect1, redirect2].filter(Boolean),
|
||||||
|
scopes,
|
||||||
|
require_pkce: true,
|
||||||
|
};
|
||||||
|
const res = await API.post('/api/oauth_clients/', payload);
|
||||||
|
if (res?.data?.success) {
|
||||||
|
setCreatedClient({ id: res.data.client_id, secret: res.data.client_secret });
|
||||||
|
showSuccess('客户端已创建');
|
||||||
|
setStep(3);
|
||||||
|
} else {
|
||||||
|
showError(res?.data?.message || '创建失败');
|
||||||
|
}
|
||||||
|
onDone && onDone();
|
||||||
|
} catch (e) {
|
||||||
|
showError('创建失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
title: '应用推荐配置',
|
||||||
|
content: (
|
||||||
|
<div style={{ paddingTop: 8 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<Form labelPosition='left' labelWidth={140}>
|
||||||
|
<Form.Switch field='enable' label='启用 OAuth2 & SSO' checkedText='开' uncheckedText='关' checked={enableOAuth} onChange={setEnableOAuth} extraText='开启后将根据推荐设置完成授权链路' />
|
||||||
|
<Form.Input field='issuer' label='发行人 (Issuer)' placeholder={origin} value={issuer} onChange={setIssuer} extraText='为空则按请求自动推断(含 X-Forwarded-Proto)' />
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text type='tertiary'>说明</Text>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Tag>grant_types: auth_code / refresh_token / client_credentials</Tag>
|
||||||
|
<Tag>PKCE: S256</Tag>
|
||||||
|
<Tag>算法: RS256</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16, paddingBottom: 12 }}>
|
||||||
|
<Button type='primary' onClick={applyRecommended} loading={loading}>一键应用</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '准备签名密钥',
|
||||||
|
content: (
|
||||||
|
<div style={{ paddingTop: 8 }}>
|
||||||
|
<Text type='tertiary'>若无密钥则初始化;如已存在建议立即轮换以生成新的 kid 并发布到 JWKS。</Text>
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Button type='primary' onClick={rotateKey} loading={loading}>初始化/轮换密钥</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建默认 OIDC 客户端',
|
||||||
|
content: (
|
||||||
|
<div style={{ paddingTop: 8 }}>
|
||||||
|
<Form labelPosition='left' labelWidth={120}>
|
||||||
|
<Form.Select field='type' label='客户端类型' value={clientType} onChange={setClientType}>
|
||||||
|
<Select.Option value='public'>公开客户端(SPA/移动端)</Select.Option>
|
||||||
|
<Select.Option value='confidential'>机密客户端(服务端)</Select.Option>
|
||||||
|
</Form.Select>
|
||||||
|
<Form.Input field='r1' label='回调 URI 1' value={redirect1} onChange={setRedirect1} />
|
||||||
|
<Form.Input field='r2' label='回调 URI 2' value={redirect2} onChange={setRedirect2} />
|
||||||
|
<Form.Select field='scopes' label='Scopes' multiple value={scopes} onChange={setScopes}>
|
||||||
|
<Select.Option value='openid'>openid</Select.Option>
|
||||||
|
<Select.Option value='profile'>profile</Select.Option>
|
||||||
|
<Select.Option value='email'>email</Select.Option>
|
||||||
|
<Select.Option value='api:read'>api:read</Select.Option>
|
||||||
|
<Select.Option value='api:write'>api:write</Select.Option>
|
||||||
|
<Select.Option value='admin'>admin</Select.Option>
|
||||||
|
</Form.Select>
|
||||||
|
</Form>
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Button type='primary' onClick={createClient} loading={loading}>创建</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '完成',
|
||||||
|
content: (
|
||||||
|
<div style={{ paddingTop: 8 }}>
|
||||||
|
{createdClient ? (
|
||||||
|
<div>
|
||||||
|
<Text>客户端已创建:</Text>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Text>Client ID:</Text> <Text code copyable>{createdClient.id}</Text>
|
||||||
|
</div>
|
||||||
|
{createdClient.secret && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Text>Client Secret(仅此一次展示):</Text> <Text code copyable>{createdClient.secret}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : <Text type='tertiary'>已完成初始化。</Text>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
title='OAuth2 一键初始化向导'
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={720}
|
||||||
|
style={{ top: 48 }}
|
||||||
|
maskClosable={false}
|
||||||
|
>
|
||||||
|
<Steps current={step} style={{ marginBottom: 16 }}>
|
||||||
|
{steps.map((s, idx) => <Steps.Step key={idx} title={s.title} />)}
|
||||||
|
</Steps>
|
||||||
|
<div style={{ paddingLeft: 8, paddingRight: 8 }}>
|
||||||
|
{steps[step].content}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
324
web/src/components/modals/oauth2/OAuth2ToolsModal.jsx
Normal file
324
web/src/components/modals/oauth2/OAuth2ToolsModal.jsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Modal, Form, Input, Button, Space, Select, Typography, Divider, Toast, TextArea } from '@douyinfe/semi-ui';
|
||||||
|
import { API } from '../../../helpers';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
async function sha256Base64Url(input) {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const data = enc.encode(input);
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const bytes = new Uint8Array(hash);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomString(len = 43) {
|
||||||
|
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||||
|
let res = '';
|
||||||
|
const array = new Uint32Array(len);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
for (let i = 0; i < len; i++) res += charset[array[i] % charset.length];
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OAuth2ToolsModal({ visible, onClose }) {
|
||||||
|
const [server, setServer] = useState({});
|
||||||
|
const [authURL, setAuthURL] = useState('');
|
||||||
|
const [issuer, setIssuer] = useState('');
|
||||||
|
const [confJSON, setConfJSON] = useState('');
|
||||||
|
const [userinfoEndpoint, setUserinfoEndpoint] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [accessToken, setAccessToken] = useState('');
|
||||||
|
const [idToken, setIdToken] = useState('');
|
||||||
|
const [refreshToken, setRefreshToken] = useState('');
|
||||||
|
const [tokenRaw, setTokenRaw] = useState('');
|
||||||
|
const [jwtClaims, setJwtClaims] = useState('');
|
||||||
|
const [userinfoOut, setUserinfoOut] = useState('');
|
||||||
|
const [values, setValues] = useState({
|
||||||
|
authorization_endpoint: '',
|
||||||
|
token_endpoint: '',
|
||||||
|
client_id: '',
|
||||||
|
client_secret: '',
|
||||||
|
redirect_uri: window.location.origin + '/oauth/oidc',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
response_type: 'code',
|
||||||
|
code_verifier: '',
|
||||||
|
code_challenge: '',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state: '',
|
||||||
|
nonce: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await API.get('/api/oauth/server-info');
|
||||||
|
if (res?.data) {
|
||||||
|
const d = res.data;
|
||||||
|
setServer(d);
|
||||||
|
setValues((v) => ({
|
||||||
|
...v,
|
||||||
|
authorization_endpoint: d.authorization_endpoint,
|
||||||
|
token_endpoint: d.token_endpoint,
|
||||||
|
}));
|
||||||
|
setIssuer(d.issuer || '');
|
||||||
|
setUserinfoEndpoint(d.userinfo_endpoint || '');
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const buildAuthorizeURL = () => {
|
||||||
|
const u = new URL(values.authorization_endpoint || (server.issuer + '/api/oauth/authorize'));
|
||||||
|
const rt = values.response_type || 'code';
|
||||||
|
u.searchParams.set('response_type', rt);
|
||||||
|
u.searchParams.set('client_id', values.client_id);
|
||||||
|
u.searchParams.set('redirect_uri', values.redirect_uri);
|
||||||
|
u.searchParams.set('scope', values.scope);
|
||||||
|
if (values.state) u.searchParams.set('state', values.state);
|
||||||
|
if (values.nonce) u.searchParams.set('nonce', values.nonce);
|
||||||
|
if (rt === 'code' && values.code_challenge) {
|
||||||
|
u.searchParams.set('code_challenge', values.code_challenge);
|
||||||
|
u.searchParams.set('code_challenge_method', values.code_challenge_method || 'S256');
|
||||||
|
}
|
||||||
|
return u.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = async (text, tip = '已复制') => {
|
||||||
|
try { await navigator.clipboard.writeText(text); Toast.success(tip); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const genVerifier = async () => {
|
||||||
|
const v = randomString(64);
|
||||||
|
const c = await sha256Base64Url(v);
|
||||||
|
setValues((val) => ({ ...val, code_verifier: v, code_challenge: c }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const discover = async () => {
|
||||||
|
const iss = (issuer || '').trim();
|
||||||
|
if (!iss) { Toast.warning('请填写 Issuer'); return; }
|
||||||
|
try {
|
||||||
|
const url = iss.replace(/\/$/, '') + '/api/.well-known/openid-configuration';
|
||||||
|
const res = await fetch(url);
|
||||||
|
const d = await res.json();
|
||||||
|
setValues((v)=>({
|
||||||
|
...v,
|
||||||
|
authorization_endpoint: d.authorization_endpoint || v.authorization_endpoint,
|
||||||
|
token_endpoint: d.token_endpoint || v.token_endpoint,
|
||||||
|
}));
|
||||||
|
setUserinfoEndpoint(d.userinfo_endpoint || '');
|
||||||
|
setIssuer(d.issuer || iss);
|
||||||
|
setConfJSON(JSON.stringify(d, null, 2));
|
||||||
|
Toast.success('已从发现文档加载端点');
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error('自动发现失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseConf = () => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(confJSON || '{}');
|
||||||
|
if (d.issuer) setIssuer(d.issuer);
|
||||||
|
if (d.authorization_endpoint) setValues((v)=>({...v, authorization_endpoint: d.authorization_endpoint}));
|
||||||
|
if (d.token_endpoint) setValues((v)=>({...v, token_endpoint: d.token_endpoint}));
|
||||||
|
if (d.userinfo_endpoint) setUserinfoEndpoint(d.userinfo_endpoint);
|
||||||
|
Toast.success('已解析配置并填充端点');
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error('解析失败:' + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const genConf = () => {
|
||||||
|
const d = {
|
||||||
|
issuer: issuer || undefined,
|
||||||
|
authorization_endpoint: values.authorization_endpoint || undefined,
|
||||||
|
token_endpoint: values.token_endpoint || undefined,
|
||||||
|
userinfo_endpoint: userinfoEndpoint || undefined,
|
||||||
|
};
|
||||||
|
setConfJSON(JSON.stringify(d, null, 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
async function postForm(url, data, basicAuth) {
|
||||||
|
const body = Object.entries(data)
|
||||||
|
.filter(([_, v]) => v !== undefined && v !== null)
|
||||||
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
||||||
|
.join('&');
|
||||||
|
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||||||
|
if (basicAuth) headers['Authorization'] = 'Basic ' + btoa(`${basicAuth.id}:${basicAuth.secret}`);
|
||||||
|
const res = await fetch(url, { method: 'POST', headers, body });
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text();
|
||||||
|
throw new Error(`HTTP ${res.status} ${t}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const exchangeCode = async () => {
|
||||||
|
try {
|
||||||
|
const basic = values.client_secret ? { id: values.client_id, secret: values.client_secret } : undefined;
|
||||||
|
const data = await postForm(values.token_endpoint, {
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: code.trim(),
|
||||||
|
client_id: values.client_id,
|
||||||
|
redirect_uri: values.redirect_uri,
|
||||||
|
code_verifier: values.code_verifier,
|
||||||
|
}, basic);
|
||||||
|
setAccessToken(data.access_token || '');
|
||||||
|
setIdToken(data.id_token || '');
|
||||||
|
setRefreshToken(data.refresh_token || '');
|
||||||
|
setTokenRaw(JSON.stringify(data, null, 2));
|
||||||
|
Toast.success('已获取令牌');
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error('兑换失败:' + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodeIdToken = () => {
|
||||||
|
const t = (idToken || '').trim();
|
||||||
|
if (!t) { setJwtClaims('(空)'); return; }
|
||||||
|
const parts = t.split('.');
|
||||||
|
if (parts.length < 2) { setJwtClaims('格式错误'); return; }
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(atob(parts[1].replace(/-/g,'+').replace(/_/g,'/')));
|
||||||
|
setJwtClaims(JSON.stringify(json, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
setJwtClaims('解码失败:' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const callUserInfo = async () => {
|
||||||
|
if (!accessToken || !userinfoEndpoint) { Toast.warning('缺少 AccessToken 或 UserInfo 端点'); return; }
|
||||||
|
try {
|
||||||
|
const res = await fetch(userinfoEndpoint, { headers: { Authorization: 'Bearer ' + accessToken } });
|
||||||
|
const data = await res.json();
|
||||||
|
setUserinfoOut(JSON.stringify(data, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
setUserinfoOut('调用失败:' + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doRefresh = async () => {
|
||||||
|
if (!refreshToken) { Toast.warning('没有刷新令牌'); return; }
|
||||||
|
try {
|
||||||
|
const basic = values.client_secret ? { id: values.client_id, secret: values.client_secret } : undefined;
|
||||||
|
const data = await postForm(values.token_endpoint, {
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_id: values.client_id,
|
||||||
|
}, basic);
|
||||||
|
setAccessToken(data.access_token || '');
|
||||||
|
setIdToken(data.id_token || '');
|
||||||
|
setRefreshToken(data.refresh_token || '');
|
||||||
|
setTokenRaw(JSON.stringify(data, null, 2));
|
||||||
|
Toast.success('刷新成功');
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error('刷新失败:' + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
title='OAuth2 调试助手'
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={<Button onClick={onClose}>关闭</Button>}
|
||||||
|
width={720}
|
||||||
|
style={{ top: 48 }}
|
||||||
|
>
|
||||||
|
{/* Discovery */}
|
||||||
|
<Typography.Title heading={6}>OIDC 发现</Typography.Title>
|
||||||
|
<Form labelPosition='left' labelWidth={140} style={{ marginBottom: 8 }}>
|
||||||
|
<Form.Input field='issuer' label='Issuer' placeholder='https://your-domain' value={issuer} onChange={setIssuer} />
|
||||||
|
</Form>
|
||||||
|
<Space style={{ marginBottom: 12 }}>
|
||||||
|
<Button onClick={discover}>自动发现端点</Button>
|
||||||
|
<Button onClick={genConf}>生成配置 JSON</Button>
|
||||||
|
<Button onClick={parseConf}>解析配置 JSON</Button>
|
||||||
|
</Space>
|
||||||
|
<TextArea value={confJSON} onChange={setConfJSON} autosize={{ minRows: 3, maxRows: 8 }} placeholder='粘贴 /.well-known/openid-configuration JSON 或点击“生成配置 JSON”' />
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Authorization URL & PKCE */}
|
||||||
|
<Typography.Title heading={6}>授权参数</Typography.Title>
|
||||||
|
<Form labelPosition='left' labelWidth={140}>
|
||||||
|
<Form.Select field='response_type' label='Response Type' value={values.response_type} onChange={(v)=>setValues({...values, response_type: v})}>
|
||||||
|
<Select.Option value='code'>code</Select.Option>
|
||||||
|
<Select.Option value='token'>token</Select.Option>
|
||||||
|
</Form.Select>
|
||||||
|
<Form.Input field='authorization_endpoint' label='Authorize URL' value={values.authorization_endpoint} onChange={(v)=>setValues({...values, authorization_endpoint: v})} />
|
||||||
|
<Form.Input field='token_endpoint' label='Token URL' value={values.token_endpoint} onChange={(v)=>setValues({...values, token_endpoint: v})} />
|
||||||
|
<Form.Input field='client_id' label='Client ID' placeholder='输入 client_id' value={values.client_id} onChange={(v)=>setValues({...values, client_id: v})} />
|
||||||
|
<Form.Input field='client_secret' label='Client Secret(可选)' placeholder='留空表示公开客户端' value={values.client_secret} onChange={(v)=>setValues({...values, client_secret: v})} />
|
||||||
|
<Form.Input field='redirect_uri' label='Redirect URI' value={values.redirect_uri} onChange={(v)=>setValues({...values, redirect_uri: v})} />
|
||||||
|
<Form.Input field='scope' label='Scope' value={values.scope} onChange={(v)=>setValues({...values, scope: v})} />
|
||||||
|
<Form.Select field='code_challenge_method' label='PKCE 方法' value={values.code_challenge_method} onChange={(v)=>setValues({...values, code_challenge_method: v})}>
|
||||||
|
<Select.Option value='S256'>S256</Select.Option>
|
||||||
|
</Form.Select>
|
||||||
|
<Form.Input field='code_verifier' label='Code Verifier' value={values.code_verifier} onChange={(v)=>setValues({...values, code_verifier: v})} suffix={<Button size='small' onClick={genVerifier}>生成</Button>} />
|
||||||
|
<Form.Input field='code_challenge' label='Code Challenge' value={values.code_challenge} onChange={(v)=>setValues({...values, code_challenge: v})} />
|
||||||
|
<Form.Input field='state' label='State' value={values.state} onChange={(v)=>setValues({...values, state: v})} suffix={<Button size='small' onClick={()=>setValues({...values, state: randomString(16)})}>随机</Button>} />
|
||||||
|
<Form.Input field='nonce' label='Nonce' value={values.nonce} onChange={(v)=>setValues({...values, nonce: v})} suffix={<Button size='small' onClick={()=>setValues({...values, nonce: randomString(16)})}>随机</Button>} />
|
||||||
|
</Form>
|
||||||
|
<Divider />
|
||||||
|
<Space style={{ marginBottom: 8 }}>
|
||||||
|
<Button onClick={()=>{ const url=buildAuthorizeURL(); setAuthURL(url); }}>生成授权链接</Button>
|
||||||
|
<Button onClick={()=>window.open(buildAuthorizeURL(), '_blank')}>打开授权URL</Button>
|
||||||
|
<Button onClick={()=>copy(buildAuthorizeURL(), '授权URL已复制')}>复制授权URL</Button>
|
||||||
|
<Button onClick={()=>copy(JSON.stringify({
|
||||||
|
authorize_url: values.authorization_endpoint,
|
||||||
|
token_url: values.token_endpoint,
|
||||||
|
client_id: values.client_id,
|
||||||
|
redirect_uri: values.redirect_uri,
|
||||||
|
scope: values.scope,
|
||||||
|
response_type: values.response_type,
|
||||||
|
code_challenge_method: values.code_challenge_method,
|
||||||
|
code_verifier: values.code_verifier,
|
||||||
|
code_challenge: values.code_challenge,
|
||||||
|
state: values.state,
|
||||||
|
nonce: values.nonce,
|
||||||
|
}, null, 2), 'oauthdebugger参数已复制')}>复制 oauthdebugger 参数</Button>
|
||||||
|
<Button onClick={()=>window.open('/oauth-demo.html', '_blank')}>打开前端 Demo</Button>
|
||||||
|
</Space>
|
||||||
|
<Form labelPosition='left' labelWidth={140}>
|
||||||
|
<Form.TextArea field='authorize_url' label='授权链接' value={authURL} onChange={setAuthURL} rows={3} placeholder='(空)' />
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Button onClick={()=>copy(authURL, '授权URL已复制')}>复制当前授权URL</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
<Text type='tertiary' style={{ display: 'block', marginTop: 8 }}>
|
||||||
|
提示:将上述参数粘贴到 oauthdebugger.com,或直接打开授权URL完成授权后回调。
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
{/* Token exchange */}
|
||||||
|
<Typography.Title heading={6}>令牌操作</Typography.Title>
|
||||||
|
<Form labelPosition='left' labelWidth={140}>
|
||||||
|
<Form.Input field='code' label='授权码 (code)' value={code} onChange={setCode} placeholder='回调后粘贴 code' />
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Space>
|
||||||
|
<Button type='primary' onClick={exchangeCode}>用 code 交换令牌</Button>
|
||||||
|
<Button onClick={doRefresh}>使用 Refresh Token 刷新</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<Form.Input field='access_token' label='Access Token' value={accessToken} onChange={setAccessToken} suffix={<Button size='small' onClick={()=>copy(accessToken,'AccessToken已复制')}>复制</Button>} />
|
||||||
|
<Form.Input field='id_token' label='ID Token' value={idToken} onChange={setIdToken} suffix={<Button size='small' onClick={decodeIdToken}>解码</Button>} />
|
||||||
|
<Form.Input field='refresh_token' label='Refresh Token' value={refreshToken} onChange={setRefreshToken} />
|
||||||
|
<Form.TextArea field='token_raw' label='原始响应' value={tokenRaw} onChange={setTokenRaw} rows={3} placeholder='(空)' />
|
||||||
|
<Form.TextArea field='jwt_claims' label='ID Token Claims' value={jwtClaims} onChange={setJwtClaims} rows={3} placeholder='(点击“解码”显示)'></Form.TextArea>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Typography.Title heading={6}>UserInfo</Typography.Title>
|
||||||
|
<Form labelPosition='left' labelWidth={140}>
|
||||||
|
<Form.Input field='userinfo_endpoint' label='UserInfo URL' value={userinfoEndpoint} onChange={setUserinfoEndpoint} />
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Button onClick={callUserInfo}>调用 UserInfo (Bearer)</Button>
|
||||||
|
</div>
|
||||||
|
<Form.TextArea field='userinfo_out' label='返回' value={userinfoOut} onChange={setUserinfoOut} rows={3} placeholder='(空)'></Form.TextArea>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,26 +18,18 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
import { Card, Spin, Space, Button } from '@douyinfe/semi-ui';
|
||||||
import { API, showError, toBoolean } from '../../helpers';
|
import { API, showError } from '../../helpers';
|
||||||
import OAuth2ServerSettings from '../../pages/Setting/OAuth2/OAuth2ServerSettings';
|
import OAuth2ServerSettings from '../../pages/Setting/OAuth2/OAuth2ServerSettings';
|
||||||
import OAuth2ClientSettings from '../../pages/Setting/OAuth2/OAuth2ClientSettings';
|
import OAuth2ClientSettings from '../../pages/Setting/OAuth2/OAuth2ClientSettings';
|
||||||
|
// import OAuth2Tools from '../../pages/Setting/OAuth2/OAuth2Tools';
|
||||||
|
import OAuth2ToolsModal from '../../components/modals/oauth2/OAuth2ToolsModal';
|
||||||
|
import OAuth2QuickStartModal from '../../components/modals/oauth2/OAuth2QuickStartModal';
|
||||||
|
import JWKSManagerModal from '../../components/modals/oauth2/JWKSManagerModal';
|
||||||
|
|
||||||
const OAuth2Setting = () => {
|
const OAuth2Setting = () => {
|
||||||
const [inputs, setInputs] = useState({
|
// 原样保存后端 Option 键值(字符串),避免类型转换造成子组件解析错误
|
||||||
'oauth2.enabled': false,
|
const [options, setOptions] = useState({});
|
||||||
'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 [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const getOptions = async () => {
|
const getOptions = async () => {
|
||||||
@@ -46,25 +38,11 @@ const OAuth2Setting = () => {
|
|||||||
const res = await API.get('/api/option/');
|
const res = await API.get('/api/option/');
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
let newInputs = {};
|
const map = {};
|
||||||
data.forEach((item) => {
|
for (const item of data) {
|
||||||
if (Object.keys(inputs).includes(item.key)) {
|
map[item.key] = item.value;
|
||||||
if (item.key === 'oauth2.allowed_grant_types') {
|
}
|
||||||
try {
|
setOptions(map);
|
||||||
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 {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
}
|
}
|
||||||
@@ -83,6 +61,10 @@ const OAuth2Setting = () => {
|
|||||||
getOptions();
|
getOptions();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [qsVisible, setQsVisible] = useState(false);
|
||||||
|
const [jwksVisible, setJwksVisible] = useState(false);
|
||||||
|
const [toolsVisible, setToolsVisible] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -92,10 +74,21 @@ const OAuth2Setting = () => {
|
|||||||
marginTop: '10px',
|
marginTop: '10px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OAuth2ServerSettings options={inputs} refresh={refresh} />
|
<Card>
|
||||||
|
<Space>
|
||||||
|
<Button type='primary' onClick={()=>setQsVisible(true)}>一键初始化向导</Button>
|
||||||
|
<Button onClick={()=>setJwksVisible(true)}>JWKS 管理</Button>
|
||||||
|
<Button onClick={()=>setToolsVisible(true)}>调试助手</Button>
|
||||||
|
<Button onClick={()=>window.open('/oauth-demo.html','_blank')}>前端 Demo</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
<OAuth2QuickStartModal visible={qsVisible} onClose={()=>setQsVisible(false)} onDone={refresh} />
|
||||||
|
<JWKSManagerModal visible={jwksVisible} onClose={()=>setJwksVisible(false)} />
|
||||||
|
<OAuth2ToolsModal visible={toolsVisible} onClose={()=>setToolsVisible(false)} />
|
||||||
|
<OAuth2ServerSettings options={options} refresh={refresh} onOpenJWKS={()=>setJwksVisible(true)} />
|
||||||
<OAuth2ClientSettings />
|
<OAuth2ClientSettings />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default OAuth2Setting;
|
export default OAuth2Setting;
|
||||||
|
|||||||
199
web/src/pages/OAuth/Consent.jsx
Normal file
199
web/src/pages/OAuth/Consent.jsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Card, Button, Typography, Tag, Space, Divider, Spin, Banner, Descriptions, Avatar, Tooltip } from '@douyinfe/semi-ui';
|
||||||
|
import { IconShield, IconTickCircle, IconClose } from '@douyinfe/semi-icons';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { API, showError } from '../../helpers';
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
function useQuery() {
|
||||||
|
const { search } = useLocation();
|
||||||
|
return useMemo(() => new URLSearchParams(search), [search]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OAuthConsent() {
|
||||||
|
const query = useQuery();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [info, setInfo] = useState(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const params = useMemo(() => {
|
||||||
|
const allowed = [
|
||||||
|
'response_type',
|
||||||
|
'client_id',
|
||||||
|
'redirect_uri',
|
||||||
|
'scope',
|
||||||
|
'state',
|
||||||
|
'code_challenge',
|
||||||
|
'code_challenge_method',
|
||||||
|
'nonce',
|
||||||
|
];
|
||||||
|
const obj = {};
|
||||||
|
allowed.forEach((k) => {
|
||||||
|
const v = query.get(k);
|
||||||
|
if (v) obj[k] = v;
|
||||||
|
});
|
||||||
|
if (!obj.response_type) obj.response_type = 'code';
|
||||||
|
return obj;
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.get('/api/oauth/authorize', {
|
||||||
|
params: { ...params, mode: 'prepare' },
|
||||||
|
// skip error toast, we'll handle gracefully
|
||||||
|
skipErrorHandler: true,
|
||||||
|
});
|
||||||
|
setInfo(res.data);
|
||||||
|
setError('');
|
||||||
|
} catch (e) {
|
||||||
|
// 401 login required or other error
|
||||||
|
setError(e?.response?.data?.error || 'failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
|
const onApprove = () => {
|
||||||
|
const u = new URL(window.location.origin + '/api/oauth/authorize');
|
||||||
|
Object.entries(params).forEach(([k, v]) => u.searchParams.set(k, v));
|
||||||
|
u.searchParams.set('approve', '1');
|
||||||
|
window.location.href = u.toString();
|
||||||
|
};
|
||||||
|
const onDeny = () => {
|
||||||
|
const u = new URL(window.location.origin + '/api/oauth/authorize');
|
||||||
|
Object.entries(params).forEach(([k, v]) => u.searchParams.set(k, v));
|
||||||
|
u.searchParams.set('deny', '1');
|
||||||
|
window.location.href = u.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderScope = () => {
|
||||||
|
if (!info?.scope_info?.length) return (
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
{info?.scope_list?.map((s) => (
|
||||||
|
<Tag key={s} style={{ marginRight: 6, marginBottom: 6 }}>{s}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
{info.scope_info.map((s) => (
|
||||||
|
<Tag key={s.Name} style={{ marginRight: 6, marginBottom: 6 }}>
|
||||||
|
<Tooltip content={s.Description || s.Name}>{s.Name}</Tooltip>
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayClient = () => (
|
||||||
|
<div>
|
||||||
|
<Space align='center' style={{ marginBottom: 6 }}>
|
||||||
|
<Avatar size='small' style={{ backgroundColor: 'var(--semi-color-tertiary)' }}>
|
||||||
|
{String(info?.client?.name || info?.client?.id || 'A').slice(0, 1).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
<Title heading={5} style={{ margin: 0 }}>{info?.client?.name || info?.client?.id}</Title>
|
||||||
|
{info?.verified && <Tag type='solid' color='green'>已验证</Tag>}
|
||||||
|
{info?.client?.type === 'public' && <Tag>公开客户端</Tag>}
|
||||||
|
{info?.client?.type === 'confidential' && <Tag color='blue'>机密客户端</Tag>}
|
||||||
|
</Space>
|
||||||
|
{info?.client?.desc && (
|
||||||
|
<Paragraph type='tertiary' style={{ marginTop: 0 }}>{info.client.desc}</Paragraph>
|
||||||
|
)}
|
||||||
|
<Descriptions size='small' style={{ marginTop: 8 }} data={[{
|
||||||
|
key: '回调域名', value: info?.redirect_host || '-',
|
||||||
|
}, {
|
||||||
|
key: '申请方域', value: info?.client?.domain || '-',
|
||||||
|
}, {
|
||||||
|
key: '需要PKCE', value: info?.require_pkce ? '是' : '否',
|
||||||
|
}]} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayUser = () => (
|
||||||
|
<Space style={{ marginTop: 8 }}>
|
||||||
|
<Avatar size='small'>{String(info?.user?.name || 'U').slice(0,1).toUpperCase()}</Avatar>
|
||||||
|
<Text>{info?.user?.name || '当前用户'}</Text>
|
||||||
|
{info?.user?.email && <Text type='tertiary'>({info.user.email})</Text>}
|
||||||
|
<Button size='small' theme='borderless' onClick={() => {
|
||||||
|
const u = new URL(window.location.origin + '/login');
|
||||||
|
u.searchParams.set('next', '/oauth/consent' + window.location.search);
|
||||||
|
window.location.href = u.toString();
|
||||||
|
}}>切换账户</Button>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 840, margin: '24px auto 48px', padding: '0 16px' }}>
|
||||||
|
<Card style={{ borderRadius: 10 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<IconShield size='extra-large' />
|
||||||
|
<div>
|
||||||
|
<Title heading={4} style={{ margin: 0 }}>应用请求访问你的账户</Title>
|
||||||
|
<Paragraph type='tertiary' style={{ margin: 0 }}>请确认是否授权下列权限给第三方应用。</Paragraph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '24px 0' }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<Banner type='warning' description={error === 'login_required' ? '请先登录后再继续授权。' : '暂时无法加载授权信息'} />
|
||||||
|
) : (
|
||||||
|
info && (
|
||||||
|
<div>
|
||||||
|
<Divider margin='12px' />
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1.3fr 0.7fr', gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
{displayClient()}
|
||||||
|
{displayUser()}
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Text type='tertiary'>请求的权限范围</Text>
|
||||||
|
{renderScope()}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Text type='tertiary'>回调地址</Text>
|
||||||
|
<Paragraph copyable style={{ marginTop: 4 }}>{info?.redirect_uri}</Paragraph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ background: 'var(--semi-color-fill-0)', border: '1px solid var(--semi-color-border)', borderRadius: 8, padding: 12 }}>
|
||||||
|
<Text type='tertiary'>安全提示</Text>
|
||||||
|
<ul style={{ margin: '8px 0 0 16px', padding: 0 }}>
|
||||||
|
<li>仅在信任的网络环境中授权。</li>
|
||||||
|
<li>确认回调域名与申请方一致{info?.verified ? '(已验证)' : '(未验证)'}。</li>
|
||||||
|
<li>你可以随时在账户设置中撤销授权。</li>
|
||||||
|
</ul>
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Descriptions size='small' data={[{
|
||||||
|
key: 'Issuer', value: window.location.origin,
|
||||||
|
}, {
|
||||||
|
key: 'Client ID', value: info?.client?.id || '-',
|
||||||
|
}, {
|
||||||
|
key: '需要PKCE', value: info?.require_pkce ? '是' : '否',
|
||||||
|
}]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingBottom: 8 }}>
|
||||||
|
<Button icon={<IconClose />} onClick={onDeny} theme='borderless'>
|
||||||
|
拒绝
|
||||||
|
</Button>
|
||||||
|
<Button icon={<IconTickCircle />} type='primary' onClick={onApprove}>
|
||||||
|
授权
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
web/src/pages/Setting/OAuth2/JWKSManager.jsx
Normal file
123
web/src/pages/Setting/OAuth2/JWKSManager.jsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Card, Table, Button, Space, Tag, Typography, Popconfirm, Toast } from '@douyinfe/semi-ui';
|
||||||
|
import { IconRefresh, IconDelete, IconPlay } from '@douyinfe/semi-icons';
|
||||||
|
import { API, showError, showSuccess } from '../../../helpers';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export default function JWKSManager() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [keys, setKeys] = useState([]);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.get('/api/oauth/keys');
|
||||||
|
if (res?.data?.success) {
|
||||||
|
setKeys(res.data.data || []);
|
||||||
|
} else {
|
||||||
|
showError(res?.data?.message || '获取密钥列表失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showError('获取密钥列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotate = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/oauth/keys/rotate', {});
|
||||||
|
if (res?.data?.success) {
|
||||||
|
showSuccess('签名密钥已轮换:' + res.data.kid);
|
||||||
|
await load();
|
||||||
|
} else {
|
||||||
|
showError(res?.data?.message || '密钥轮换失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showError('密钥轮换失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const del = async (kid) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.delete(`/api/oauth/keys/${kid}`);
|
||||||
|
if (res?.data?.success) {
|
||||||
|
Toast.success('已删除:' + kid);
|
||||||
|
await load();
|
||||||
|
} else {
|
||||||
|
showError(res?.data?.message || '删除失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showError('删除失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: 'KID',
|
||||||
|
dataIndex: 'kid',
|
||||||
|
render: (kid) => <Text code copyable>{kid}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
render: (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'current',
|
||||||
|
render: (cur) => (cur ? <Tag color='green'>当前</Tag> : <Tag>历史</Tag>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
render: (_, r) => (
|
||||||
|
<Space>
|
||||||
|
{!r.current && (
|
||||||
|
<Popconfirm
|
||||||
|
title={`确定删除密钥 ${r.kid} ?`}
|
||||||
|
content='删除后使用该 kid 签发的旧令牌仍可被验证(若 JWKS 已被其他方缓存,建议保留一段时间)'
|
||||||
|
okText='删除'
|
||||||
|
onConfirm={() => del(r.kid)}
|
||||||
|
>
|
||||||
|
<Button icon={<IconDelete />} size='small' theme='borderless'>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title='JWKS 管理'
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button icon={<IconRefresh />} onClick={load} loading={loading}>刷新</Button>
|
||||||
|
<Button icon={<IconPlay />} type='primary' onClick={rotate} loading={loading}>轮换密钥</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
style={{ marginTop: 10 }}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
dataSource={keys}
|
||||||
|
columns={columns}
|
||||||
|
rowKey='kid'
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
empty={<Text type='tertiary'>暂无密钥</Text>}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -193,20 +193,37 @@ export default function OAuth2ClientSettings() {
|
|||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
{record.client_type === 'confidential' && (
|
{record.client_type === 'confidential' && (
|
||||||
<Button
|
<Popconfirm
|
||||||
theme="borderless"
|
title="确认重新生成客户端密钥?"
|
||||||
type="secondary"
|
content={
|
||||||
size="small"
|
<div>
|
||||||
onClick={() => handleRegenerateSecret(record)}
|
<div>客户端:{record.name}</div>
|
||||||
|
<div style={{ marginTop: 6 }}>操作不可撤销,旧密钥将立即失效。</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
onConfirm={() => handleRegenerateSecret(record)}
|
||||||
|
okText="确认"
|
||||||
|
cancelText="取消"
|
||||||
>
|
>
|
||||||
重新生成密钥
|
<Button
|
||||||
</Button>
|
theme="borderless"
|
||||||
|
type="secondary"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
重新生成密钥
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确定删除这个OAuth2客户端吗?"
|
title="请再次确认删除该客户端"
|
||||||
content="删除后无法恢复,相关的API访问将失效。"
|
content={
|
||||||
|
<div>
|
||||||
|
<div>客户端:{record.name}</div>
|
||||||
|
<div style={{ marginTop: 6, color: 'var(--semi-color-danger)' }}>删除后无法恢复,相关 API 调用将立即失效。</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
onConfirm={() => handleDelete(record)}
|
onConfirm={() => handleDelete(record)}
|
||||||
okText="确定"
|
okText="确定删除"
|
||||||
cancelText="取消"
|
cancelText="取消"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -417,4 +434,4 @@ export default function OAuth2ClientSettings() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
131
web/src/pages/Setting/OAuth2/OAuth2QuickStart.jsx
Normal file
131
web/src/pages/Setting/OAuth2/OAuth2QuickStart.jsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Card, Typography, Button, Space, Steps, Form, Input, Select, Tag, Toast } from '@douyinfe/semi-ui';
|
||||||
|
import { API, showError, showSuccess } from '../../../helpers';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
export default function OAuth2QuickStart({ onChanged }) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const origin = useMemo(() => window.location.origin, []);
|
||||||
|
const [client, setClient] = useState({
|
||||||
|
name: 'Default OIDC Client',
|
||||||
|
client_type: 'public',
|
||||||
|
redirect_uris: [origin + '/oauth/oidc', ''],
|
||||||
|
scopes: ['openid', 'profile', 'email', 'api:read'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyRecommended = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const ops = [
|
||||||
|
{ key: 'oauth2.enabled', value: 'true' },
|
||||||
|
{ key: 'oauth2.issuer', value: origin },
|
||||||
|
{ key: 'oauth2.allowed_grant_types', value: JSON.stringify(['authorization_code', 'refresh_token', 'client_credentials']) },
|
||||||
|
{ key: 'oauth2.require_pkce', value: 'true' },
|
||||||
|
{ key: 'oauth2.jwt_signing_algorithm', value: 'RS256' },
|
||||||
|
];
|
||||||
|
for (const op of ops) {
|
||||||
|
await API.put('/api/option/', op);
|
||||||
|
}
|
||||||
|
showSuccess('已应用推荐配置');
|
||||||
|
onChanged && onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
showError('应用推荐配置失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureKey = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await API.get('/api/oauth/keys');
|
||||||
|
const list = res?.data?.data || [];
|
||||||
|
if (list.length === 0) {
|
||||||
|
const r = await API.post('/api/oauth/keys/rotate', {});
|
||||||
|
if (r?.data?.success) showSuccess('已初始化签名密钥');
|
||||||
|
} else {
|
||||||
|
const r = await API.post('/api/oauth/keys/rotate', {});
|
||||||
|
if (r?.data?.success) showSuccess('已轮换签名密钥:' + r.data.kid);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showError('签名密钥操作失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createClient = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const grant_types = client.client_type === 'public'
|
||||||
|
? ['authorization_code', 'refresh_token']
|
||||||
|
: ['authorization_code', 'refresh_token', 'client_credentials'];
|
||||||
|
const payload = {
|
||||||
|
name: client.name,
|
||||||
|
client_type: client.client_type,
|
||||||
|
grant_types,
|
||||||
|
redirect_uris: client.redirect_uris.filter(Boolean),
|
||||||
|
scopes: client.scopes,
|
||||||
|
require_pkce: true,
|
||||||
|
};
|
||||||
|
const res = await API.post('/api/oauth_clients/', payload);
|
||||||
|
if (res?.data?.success) {
|
||||||
|
Toast.success('客户端已创建:' + res.data.client_id);
|
||||||
|
onChanged && onChanged();
|
||||||
|
} else {
|
||||||
|
showError(res?.data?.message || '创建失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showError('创建失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card style={{ marginTop: 10 }}>
|
||||||
|
<Title heading={5} style={{ marginBottom: 8 }}>OAuth2 一键初始化</Title>
|
||||||
|
<Text type='tertiary'>按顺序完成以下步骤,系统将自动完成推荐设置、签名密钥准备、客户端创建与回调配置。</Text>
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Steps current={-1} type='basic' direction='vertical'>
|
||||||
|
<Steps.Step title='应用推荐配置' description='启用 OAuth2,设置发行人(Issuer)为当前域名,启用授权码+PKCE、刷新令牌、客户端凭证。'>
|
||||||
|
<Button onClick={applyRecommended} loading={busy} style={{ marginTop: 8 }}>一键应用</Button>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Tag>issuer = {origin}</Tag>{' '}
|
||||||
|
<Tag>grant_types = auth_code / refresh_token / client_credentials</Tag>{' '}
|
||||||
|
<Tag>PKCE = S256</Tag>
|
||||||
|
</div>
|
||||||
|
</Steps.Step>
|
||||||
|
<Steps.Step title='准备签名密钥' description='若无密钥则初始化;如已存在,建议立即轮换以生成新的 kid。'>
|
||||||
|
<Button onClick={ensureKey} loading={busy} style={{ marginTop: 8 }}>初始化/轮换</Button>
|
||||||
|
</Steps.Step>
|
||||||
|
<Steps.Step title='创建 OIDC 客户端' description='创建一个默认客户端,预置常用回调与 scope,可直接用于调试与集成。'>
|
||||||
|
<Form labelPosition='left' labelWidth={120} style={{ marginTop: 8 }}>
|
||||||
|
<Form.Input label='名称' value={client.name} onChange={(v)=>setClient({...client, name: v})} />
|
||||||
|
<Form.Select label='类型' value={client.client_type} onChange={(v)=>setClient({...client, client_type: v})}>
|
||||||
|
<Select.Option value='public'>公开客户端</Select.Option>
|
||||||
|
<Select.Option value='confidential'>机密客户端</Select.Option>
|
||||||
|
</Form.Select>
|
||||||
|
<Form.Input label='回调 URI 1' value={client.redirect_uris[0]} onChange={(v)=>{
|
||||||
|
const arr=[...client.redirect_uris]; arr[0]=v; setClient({...client, redirect_uris: arr});
|
||||||
|
}} />
|
||||||
|
<Form.Input label='回调 URI 2' value={client.redirect_uris[1]} onChange={(v)=>{
|
||||||
|
const arr=[...client.redirect_uris]; arr[1]=v; setClient({...client, redirect_uris: arr});
|
||||||
|
}} />
|
||||||
|
<Form.Select label='Scopes' multiple value={client.scopes} onChange={(v)=>setClient({...client, scopes: v})}>
|
||||||
|
<Select.Option value='openid'>openid</Select.Option>
|
||||||
|
<Select.Option value='profile'>profile</Select.Option>
|
||||||
|
<Select.Option value='email'>email</Select.Option>
|
||||||
|
<Select.Option value='api:read'>api:read</Select.Option>
|
||||||
|
<Select.Option value='api:write'>api:write</Select.Option>
|
||||||
|
<Select.Option value='admin'>admin</Select.Option>
|
||||||
|
</Form.Select>
|
||||||
|
</Form>
|
||||||
|
<Button type='primary' onClick={createClient} loading={busy} style={{ marginTop: 8 }}>创建默认客户端</Button>
|
||||||
|
</Steps.Step>
|
||||||
|
</Steps>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,14 +39,17 @@ export default function OAuth2ServerSettings(props) {
|
|||||||
'oauth2.jwt_signing_algorithm': 'RS256',
|
'oauth2.jwt_signing_algorithm': 'RS256',
|
||||||
'oauth2.jwt_key_id': 'oauth2-key-1',
|
'oauth2.jwt_key_id': 'oauth2-key-1',
|
||||||
'oauth2.jwt_private_key_file': '',
|
'oauth2.jwt_private_key_file': '',
|
||||||
'oauth2.allowed_grant_types': ['client_credentials', 'authorization_code'],
|
'oauth2.allowed_grant_types': ['client_credentials', 'authorization_code', 'refresh_token'],
|
||||||
'oauth2.require_pkce': true,
|
'oauth2.require_pkce': true,
|
||||||
'oauth2.auto_create_user': false,
|
'oauth2.auto_create_user': false,
|
||||||
'oauth2.default_user_role': 1,
|
'oauth2.default_user_role': 1,
|
||||||
'oauth2.default_user_group': 'default',
|
'oauth2.default_user_group': 'default',
|
||||||
|
'oauth2.max_jwks_keys': 3,
|
||||||
});
|
});
|
||||||
const refForm = useRef();
|
const refForm = useRef();
|
||||||
const [inputsRow, setInputsRow] = useState(inputs);
|
const [inputsRow, setInputsRow] = useState(inputs);
|
||||||
|
const [keysReady, setKeysReady] = useState(true);
|
||||||
|
const [keysLoading, setKeysLoading] = useState(false);
|
||||||
|
|
||||||
function handleFieldChange(fieldName) {
|
function handleFieldChange(fieldName) {
|
||||||
return (value) => {
|
return (value) => {
|
||||||
@@ -97,10 +100,11 @@ export default function OAuth2ServerSettings(props) {
|
|||||||
const testOAuth2 = async () => {
|
const testOAuth2 = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await API.get('/api/oauth/server-info');
|
const res = await API.get('/api/oauth/server-info');
|
||||||
if (res.data.success) {
|
// 只要返回了issuer等关键字段即可视为成功
|
||||||
|
if (res.status === 200 && (res.data.issuer || res.data.authorization_endpoint)) {
|
||||||
showSuccess('OAuth2服务器运行正常');
|
showSuccess('OAuth2服务器运行正常');
|
||||||
} else {
|
} else {
|
||||||
showError('OAuth2服务器测试失败: ' + res.data.message);
|
showError('OAuth2服务器测试失败');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('OAuth2服务器连接测试失败');
|
showError('OAuth2服务器连接测试失败');
|
||||||
@@ -114,9 +118,9 @@ export default function OAuth2ServerSettings(props) {
|
|||||||
if (Object.keys(inputs).includes(key)) {
|
if (Object.keys(inputs).includes(key)) {
|
||||||
if (key === 'oauth2.allowed_grant_types') {
|
if (key === 'oauth2.allowed_grant_types') {
|
||||||
try {
|
try {
|
||||||
currentInputs[key] = JSON.parse(props.options[key] || '["client_credentials","authorization_code"]');
|
currentInputs[key] = JSON.parse(props.options[key] || '["client_credentials","authorization_code","refresh_token"]');
|
||||||
} catch {
|
} catch {
|
||||||
currentInputs[key] = ['client_credentials', 'authorization_code'];
|
currentInputs[key] = ['client_credentials', 'authorization_code', 'refresh_token'];
|
||||||
}
|
}
|
||||||
} else if (typeof inputs[key] === 'boolean') {
|
} else if (typeof inputs[key] === 'boolean') {
|
||||||
currentInputs[key] = props.options[key] === 'true';
|
currentInputs[key] = props.options[key] === 'true';
|
||||||
@@ -135,61 +139,92 @@ export default function OAuth2ServerSettings(props) {
|
|||||||
}
|
}
|
||||||
}, [props]);
|
}, [props]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadKeys = async () => {
|
||||||
|
try {
|
||||||
|
setKeysLoading(true);
|
||||||
|
const res = await API.get('/api/oauth/keys', { skipErrorHandler: true });
|
||||||
|
const list = res?.data?.data || [];
|
||||||
|
setKeysReady(list.length > 0);
|
||||||
|
} catch {
|
||||||
|
setKeysReady(false);
|
||||||
|
} finally {
|
||||||
|
setKeysLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (inputs['oauth2.enabled']) {
|
||||||
|
loadKeys();
|
||||||
|
}
|
||||||
|
}, [inputs['oauth2.enabled']]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Card>
|
{/* 移除重复的顶卡片,统一在下方“基础配置”中显示开关与 Issuer */}
|
||||||
|
|
||||||
|
{/* 开关与基础配置 */}
|
||||||
|
<Card style={{ marginTop: 10 }}>
|
||||||
<Form
|
<Form
|
||||||
initValues={inputs}
|
initValues={inputs}
|
||||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||||
>
|
>
|
||||||
<Form.Section text={'OAuth2 服务器设置'}>
|
<Form.Section text={'基础配置'}>
|
||||||
<Banner
|
{!keysReady && inputs['oauth2.enabled'] && (
|
||||||
type="info"
|
<Banner
|
||||||
description={
|
type='warning'
|
||||||
<div>
|
description={<div>
|
||||||
<p>• OAuth2服务器提供标准的API认证和授权功能</p>
|
<div>尚未准备签名密钥,建议立即初始化或轮换以发布 JWKS。</div>
|
||||||
<p>• 支持Client Credentials、Authorization Code + PKCE等标准流程</p>
|
<div>签名密钥用于 JWT 令牌的安全签发。</div>
|
||||||
<p>• 更改配置后需要重启服务才能生效</p>
|
</div>}
|
||||||
<p>• 生产环境务必配置HTTPS和安全的JWT签名密钥</p>
|
actions={<Button size='small' onClick={() => props?.onOpenJWKS && props.onOpenJWKS()} loading={keysLoading}>打开密钥向导</Button>}
|
||||||
</div>
|
style={{ marginBottom: 12 }}
|
||||||
}
|
/>
|
||||||
style={{ marginBottom: 15 }}
|
)}
|
||||||
/>
|
|
||||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
<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}>
|
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||||
<Form.Switch
|
<Form.Switch
|
||||||
field='oauth2.enabled'
|
field='oauth2.enabled'
|
||||||
label={t('启用OAuth2服务器')}
|
label={t('启用 OAuth2 & SSO')}
|
||||||
checkedText='开'
|
checkedText='开'
|
||||||
uncheckedText='关'
|
uncheckedText='关'
|
||||||
value={inputs['oauth2.enabled']}
|
value={inputs['oauth2.enabled']}
|
||||||
onChange={handleFieldChange('oauth2.enabled')}
|
onChange={handleFieldChange('oauth2.enabled')}
|
||||||
|
extraText="开启后将允许以 OAuth2/OIDC 标准进行授权与登录"
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||||
<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
|
<Form.Input
|
||||||
field='oauth2.issuer'
|
field='oauth2.issuer'
|
||||||
label={t('签发者标识(Issuer)')}
|
label={t('发行人 (Issuer)')}
|
||||||
placeholder="https://your-domain.com"
|
placeholder={window.location.origin}
|
||||||
extraText="OAuth2令牌的签发者,通常是您的域名"
|
|
||||||
value={inputs['oauth2.issuer']}
|
value={inputs['oauth2.issuer']}
|
||||||
onChange={handleFieldChange('oauth2.issuer')}
|
onChange={handleFieldChange('oauth2.issuer')}
|
||||||
|
extraText="为空则按请求自动推断(含 X-Forwarded-Proto)"
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Button onClick={onSubmit} loading={loading}>{t('更新服务器设置')}</Button>
|
<Button onClick={onSubmit} loading={loading}>{t('更新基础配置')}</Button>
|
||||||
</Form.Section>
|
</Form.Section>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{inputs['oauth2.enabled'] && (
|
||||||
|
<>
|
||||||
<Card style={{ marginTop: 10 }}>
|
<Card style={{ marginTop: 10 }}>
|
||||||
<Form
|
<Form
|
||||||
initValues={inputs}
|
initValues={inputs}
|
||||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||||
>
|
>
|
||||||
<Form.Section text={'令牌配置'}>
|
<Form.Section text={'令牌配置'}>
|
||||||
|
<Banner
|
||||||
|
type='info'
|
||||||
|
description={<div>
|
||||||
|
<div>• OAuth2 服务器提供标准的 API 认证与授权</div>
|
||||||
|
<div>• 支持 Client Credentials、Authorization Code + PKCE 等标准流程</div>
|
||||||
|
<div>• 配置保存后多数项即时生效;签名密钥轮换与 JWKS 发布为即时操作;一般无需重启</div>
|
||||||
|
<div>• 生产环境务必启用 HTTPS,并妥善管理 JWT 签名密钥</div>
|
||||||
|
</div>}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
<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}>
|
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||||
<Form.InputNumber
|
<Form.InputNumber
|
||||||
@@ -239,18 +274,34 @@ export default function OAuth2ServerSettings(props) {
|
|||||||
<Form.Select.Option value="HS256">HS256 (HMAC with SHA-256)</Form.Select.Option>
|
<Form.Select.Option value="HS256">HS256 (HMAC with SHA-256)</Form.Select.Option>
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
</Col>
|
</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="如需外部文件私钥,可在此指定路径;推荐使用内存密钥 + JWKS 轮换(更安全便捷)"*/}
|
||||||
|
{/* />*/}
|
||||||
|
{/*</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}>
|
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||||
<Form.Input
|
<Form.InputNumber
|
||||||
field='oauth2.jwt_private_key_file'
|
field='oauth2.max_jwks_keys'
|
||||||
label={t('JWT私钥文件路径')}
|
label='JWKS历史保留上限'
|
||||||
placeholder="/path/to/oauth2-private-key.pem"
|
min={1}
|
||||||
value={inputs['oauth2.jwt_private_key_file']}
|
max={10}
|
||||||
onChange={handleFieldChange('oauth2.jwt_private_key_file')}
|
value={inputs['oauth2.max_jwks_keys']}
|
||||||
extraText="RSA私钥文件路径,留空将使用内存生成的密钥"
|
onChange={handleFieldChange('oauth2.max_jwks_keys')}
|
||||||
|
extraText="轮换后最多保留的历史签名密钥数量(越少越安全,建议 3)"
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Button onClick={onSubmit} loading={loading}>{t('更新令牌配置')}</Button>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button onClick={onSubmit} loading={loading}>{t('更新令牌配置')}</Button>
|
||||||
|
<Button type='secondary' onClick={() => props && props.onOpenJWKS && props.onOpenJWKS()}>密钥向导(JWKS)</Button>
|
||||||
|
</div>
|
||||||
</Form.Section>
|
</Form.Section>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -293,59 +344,61 @@ export default function OAuth2ServerSettings(props) {
|
|||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card style={{ marginTop: 10 }}>
|
{/*<Card style={{ marginTop: 10 }}>*/}
|
||||||
<Form
|
{/* <Form*/}
|
||||||
initValues={inputs}
|
{/* initValues={inputs}*/}
|
||||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
{/* getFormApi={(formAPI) => (refForm.current = formAPI)}*/}
|
||||||
>
|
{/* >*/}
|
||||||
<Form.Section text={'用户配置'}>
|
{/* <Form.Section text={'用户配置'}>*/}
|
||||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
{/* <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}>
|
{/* <Col xs={24} sm={24} md={8} lg={8} xl={8}>*/}
|
||||||
<Form.Switch
|
{/* <Form.Switch*/}
|
||||||
field='oauth2.auto_create_user'
|
{/* field='oauth2.auto_create_user'*/}
|
||||||
label={t('自动创建用户')}
|
{/* label={t('自动创建用户')}*/}
|
||||||
checkedText='开'
|
{/* checkedText='开'*/}
|
||||||
uncheckedText='关'
|
{/* uncheckedText='关'*/}
|
||||||
value={inputs['oauth2.auto_create_user']}
|
{/* value={inputs['oauth2.auto_create_user']}*/}
|
||||||
onChange={handleFieldChange('oauth2.auto_create_user')}
|
{/* onChange={handleFieldChange('oauth2.auto_create_user')}*/}
|
||||||
extraText="首次OAuth2登录时自动创建用户账户"
|
{/* extraText="首次OAuth2登录时自动创建用户账户"*/}
|
||||||
/>
|
{/* />*/}
|
||||||
</Col>
|
{/* </Col>*/}
|
||||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
{/* <Col xs={24} sm={24} md={8} lg={8} xl={8}>*/}
|
||||||
<Form.Select
|
{/* <Form.Select*/}
|
||||||
field='oauth2.default_user_role'
|
{/* field='oauth2.default_user_role'*/}
|
||||||
label={t('默认用户角色')}
|
{/* label={t('默认用户角色')}*/}
|
||||||
value={inputs['oauth2.default_user_role']}
|
{/* value={inputs['oauth2.default_user_role']}*/}
|
||||||
onChange={handleFieldChange('oauth2.default_user_role')}
|
{/* onChange={handleFieldChange('oauth2.default_user_role')}*/}
|
||||||
extraText="自动创建用户时的默认角色"
|
{/* extraText="自动创建用户时的默认角色"*/}
|
||||||
>
|
{/* >*/}
|
||||||
<Form.Select.Option value={1}>普通用户</Form.Select.Option>
|
{/* <Form.Select.Option value={1}>普通用户</Form.Select.Option>*/}
|
||||||
<Form.Select.Option value={10}>管理员</Form.Select.Option>
|
{/* <Form.Select.Option value={10}>管理员</Form.Select.Option>*/}
|
||||||
<Form.Select.Option value={100}>超级管理员</Form.Select.Option>
|
{/* <Form.Select.Option value={100}>超级管理员</Form.Select.Option>*/}
|
||||||
</Form.Select>
|
{/* </Form.Select>*/}
|
||||||
</Col>
|
{/* </Col>*/}
|
||||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
{/* <Col xs={24} sm={24} md={8} lg={8} xl={8}>*/}
|
||||||
<Form.Input
|
{/* <Form.Input*/}
|
||||||
field='oauth2.default_user_group'
|
{/* field='oauth2.default_user_group'*/}
|
||||||
label={t('默认用户分组')}
|
{/* label={t('默认用户分组')}*/}
|
||||||
placeholder="default"
|
{/* placeholder="default"*/}
|
||||||
value={inputs['oauth2.default_user_group']}
|
{/* value={inputs['oauth2.default_user_group']}*/}
|
||||||
onChange={handleFieldChange('oauth2.default_user_group')}
|
{/* onChange={handleFieldChange('oauth2.default_user_group')}*/}
|
||||||
extraText="自动创建用户时的默认分组"
|
{/* extraText="自动创建用户时的默认分组"*/}
|
||||||
/>
|
{/* />*/}
|
||||||
</Col>
|
{/* </Col>*/}
|
||||||
</Row>
|
{/* </Row>*/}
|
||||||
<Button onClick={onSubmit} loading={loading}>{t('更新用户配置')}</Button>
|
{/* <Button onClick={onSubmit} loading={loading}>{t('更新用户配置')}</Button>*/}
|
||||||
<Button
|
{/* <Button*/}
|
||||||
type="secondary"
|
{/* type="secondary"*/}
|
||||||
onClick={testOAuth2}
|
{/* onClick={testOAuth2}*/}
|
||||||
style={{ marginLeft: 8 }}
|
{/* style={{ marginLeft: 8 }}*/}
|
||||||
>
|
{/* >*/}
|
||||||
{t('测试连接')}
|
{/* {t('测试连接')}*/}
|
||||||
</Button>
|
{/* </Button>*/}
|
||||||
</Form.Section>
|
{/* </Form.Section>*/}
|
||||||
</Form>
|
{/* </Form>*/}
|
||||||
</Card>
|
{/*</Card>*/}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
129
web/src/pages/Setting/OAuth2/OAuth2Tools.jsx
Normal file
129
web/src/pages/Setting/OAuth2/OAuth2Tools.jsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Card, Form, Input, Button, Space, Typography, Divider, Toast, Select } from '@douyinfe/semi-ui';
|
||||||
|
import { API } from '../../../helpers';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
async function sha256Base64Url(input) {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const data = enc.encode(input);
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const bytes = new Uint8Array(hash);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomString(len = 43) {
|
||||||
|
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||||
|
let res = '';
|
||||||
|
const array = new Uint32Array(len);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
for (let i = 0; i < len; i++) res += charset[array[i] % charset.length];
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OAuth2Tools() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [server, setServer] = useState({});
|
||||||
|
const [values, setValues] = useState({
|
||||||
|
authorization_endpoint: '',
|
||||||
|
token_endpoint: '',
|
||||||
|
client_id: '',
|
||||||
|
redirect_uri: window.location.origin + '/oauth/oidc',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
response_type: 'code',
|
||||||
|
code_verifier: '',
|
||||||
|
code_challenge: '',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state: '',
|
||||||
|
nonce: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await API.get('/api/oauth/server-info');
|
||||||
|
if (res?.data) {
|
||||||
|
const d = res.data;
|
||||||
|
setServer(d);
|
||||||
|
setValues((v) => ({
|
||||||
|
...v,
|
||||||
|
authorization_endpoint: d.authorization_endpoint,
|
||||||
|
token_endpoint: d.token_endpoint,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buildAuthorizeURL = () => {
|
||||||
|
const u = new URL(values.authorization_endpoint || (server.issuer + '/oauth/authorize'));
|
||||||
|
u.searchParams.set('response_type', values.response_type || 'code');
|
||||||
|
u.searchParams.set('client_id', values.client_id);
|
||||||
|
u.searchParams.set('redirect_uri', values.redirect_uri);
|
||||||
|
u.searchParams.set('scope', values.scope);
|
||||||
|
if (values.state) u.searchParams.set('state', values.state);
|
||||||
|
if (values.nonce) u.searchParams.set('nonce', values.nonce);
|
||||||
|
if (values.code_challenge) {
|
||||||
|
u.searchParams.set('code_challenge', values.code_challenge);
|
||||||
|
u.searchParams.set('code_challenge_method', values.code_challenge_method || 'S256');
|
||||||
|
}
|
||||||
|
return u.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = async (text, tip = '已复制') => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
Toast.success(tip);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const genVerifier = async () => {
|
||||||
|
const v = randomString(64);
|
||||||
|
const c = await sha256Base64Url(v);
|
||||||
|
setValues((val) => ({ ...val, code_verifier: v, code_challenge: c }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card style={{ marginTop: 10 }} title='OAuth2 调试助手'>
|
||||||
|
<Form labelPosition='left' labelWidth={140}>
|
||||||
|
<Form.Input field='authorization_endpoint' label='Authorize URL' value={values.authorization_endpoint} onChange={(v)=>setValues({...values, authorization_endpoint: v})} />
|
||||||
|
<Form.Input field='token_endpoint' label='Token URL' value={values.token_endpoint} onChange={(v)=>setValues({...values, token_endpoint: v})} />
|
||||||
|
<Form.Input field='client_id' label='Client ID' placeholder='输入 client_id' value={values.client_id} onChange={(v)=>setValues({...values, client_id: v})} />
|
||||||
|
<Form.Input field='redirect_uri' label='Redirect URI' value={values.redirect_uri} onChange={(v)=>setValues({...values, redirect_uri: v})} />
|
||||||
|
<Form.Input field='scope' label='Scope' value={values.scope} onChange={(v)=>setValues({...values, scope: v})} />
|
||||||
|
<Form.Select field='code_challenge_method' label='PKCE 方法' value={values.code_challenge_method} onChange={(v)=>setValues({...values, code_challenge_method: v})}>
|
||||||
|
<Select.Option value='S256'>S256</Select.Option>
|
||||||
|
</Form.Select>
|
||||||
|
<Form.Input field='code_verifier' label='Code Verifier' value={values.code_verifier} onChange={(v)=>setValues({...values, code_verifier: v})} suffix={
|
||||||
|
<Button size='small' onClick={genVerifier}>生成</Button>
|
||||||
|
} />
|
||||||
|
<Form.Input field='code_challenge' label='Code Challenge' value={values.code_challenge} onChange={(v)=>setValues({...values, code_challenge: v})} />
|
||||||
|
<Form.Input field='state' label='State' value={values.state} onChange={(v)=>setValues({...values, state: v})} suffix={<Button size='small' onClick={()=>setValues({...values, state: randomString(16)})}>随机</Button>} />
|
||||||
|
<Form.Input field='nonce' label='Nonce' value={values.nonce} onChange={(v)=>setValues({...values, nonce: v})} suffix={<Button size='small' onClick={()=>setValues({...values, nonce: randomString(16)})}>随机</Button>} />
|
||||||
|
</Form>
|
||||||
|
<Divider />
|
||||||
|
<Space>
|
||||||
|
<Button onClick={()=>window.open(buildAuthorizeURL(), '_blank')}>打开授权URL</Button>
|
||||||
|
<Button onClick={()=>copy(buildAuthorizeURL(), '授权URL已复制')}>复制授权URL</Button>
|
||||||
|
<Button onClick={()=>copy(JSON.stringify({
|
||||||
|
authorize_url: values.authorization_endpoint,
|
||||||
|
token_url: values.token_endpoint,
|
||||||
|
client_id: values.client_id,
|
||||||
|
redirect_uri: values.redirect_uri,
|
||||||
|
scope: values.scope,
|
||||||
|
code_challenge_method: values.code_challenge_method,
|
||||||
|
code_verifier: values.code_verifier,
|
||||||
|
code_challenge: values.code_challenge,
|
||||||
|
state: values.state,
|
||||||
|
nonce: values.nonce,
|
||||||
|
}, null, 2), 'oauthdebugger参数已复制')}>复制 oauthdebugger 参数</Button>
|
||||||
|
</Space>
|
||||||
|
<Text type='tertiary' style={{ display: 'block', marginTop: 8 }}>
|
||||||
|
提示:将上述参数粘贴到 oauthdebugger.com,或直接打开授权URL完成授权后回调。
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user