mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 10:14:41 +00:00
feat: oauth2
This commit is contained in:
@@ -3,10 +3,16 @@ package controller
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"one-api/model"
|
||||
"one-api/setting/system_setting"
|
||||
"one-api/src/oauth"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
"one-api/middleware"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetJWKS 获取JWKS公钥集
|
||||
@@ -19,6 +25,9 @@ func GetJWKS(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// lazy init if needed
|
||||
_ = oauth.EnsureInitialized()
|
||||
|
||||
jwks := oauth.GetJWKS()
|
||||
if jwks == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -70,7 +79,7 @@ func OAuthTokenEndpoint(c *gin.Context) {
|
||||
|
||||
// 只允许application/x-www-form-urlencoded内容类型
|
||||
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{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Content-Type must be application/x-www-form-urlencoded",
|
||||
@@ -78,7 +87,11 @@ func OAuthTokenEndpoint(c *gin.Context) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -93,7 +106,10 @@ func OAuthAuthorizeEndpoint(c *gin.Context) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -108,20 +124,68 @@ func OAuthServerInfo(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 返回OAuth2服务器的基本信息(类似OpenID Connect Discovery)
|
||||
issuer := settings.Issuer
|
||||
if issuer == "" {
|
||||
scheme := "https"
|
||||
if c.Request.TLS == nil {
|
||||
if hdr := c.Request.Header.Get("X-Forwarded-Proto"); hdr != "" {
|
||||
scheme = hdr
|
||||
} else {
|
||||
scheme = "http"
|
||||
}
|
||||
}
|
||||
issuer = scheme + "://" + c.Request.Host
|
||||
}
|
||||
|
||||
base := issuer + "/api"
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"issuer": settings.Issuer,
|
||||
"authorization_endpoint": settings.Issuer + "/oauth/authorize",
|
||||
"token_endpoint": settings.Issuer + "/oauth/token",
|
||||
"jwks_uri": settings.Issuer + "/.well-known/jwks.json",
|
||||
"issuer": issuer,
|
||||
"authorization_endpoint": base + "/oauth/authorize",
|
||||
"token_endpoint": base + "/oauth/token",
|
||||
"jwks_uri": base + "/.well-known/jwks.json",
|
||||
"grant_types_supported": settings.AllowedGrantTypes,
|
||||
"response_types_supported": []string{"code"},
|
||||
"response_types_supported": []string{"code", "token"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"scopes_supported": []string{
|
||||
"api:read",
|
||||
"api:write",
|
||||
"admin",
|
||||
},
|
||||
"scopes_supported": []string{"openid", "profile", "email", "api:read", "api:write", "admin"},
|
||||
"default_private_key_path": settings.DefaultPrivateKeyPath,
|
||||
})
|
||||
}
|
||||
|
||||
// OAuthOIDCConfiguration OIDC discovery document
|
||||
func OAuthOIDCConfiguration(c *gin.Context) {
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
if !settings.Enabled {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "OAuth2 server is disabled"})
|
||||
return
|
||||
}
|
||||
issuer := settings.Issuer
|
||||
if issuer == "" {
|
||||
scheme := "https"
|
||||
if c.Request.TLS == nil {
|
||||
if hdr := c.Request.Header.Get("X-Forwarded-Proto"); hdr != "" {
|
||||
scheme = hdr
|
||||
} else {
|
||||
scheme = "http"
|
||||
}
|
||||
}
|
||||
issuer = scheme + "://" + c.Request.Host
|
||||
}
|
||||
base := issuer + "/api"
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"issuer": issuer,
|
||||
"authorization_endpoint": base + "/oauth/authorize",
|
||||
"token_endpoint": base + "/oauth/token",
|
||||
"userinfo_endpoint": base + "/oauth/userinfo",
|
||||
"jwks_uri": base + "/.well-known/jwks.json",
|
||||
"response_types_supported": []string{"code", "token"},
|
||||
"grant_types_supported": settings.AllowedGrantTypes,
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
"scopes_supported": []string{"openid", "profile", "email", "api:read", "api:write", "admin"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"default_private_key_path": settings.DefaultPrivateKeyPath,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -152,14 +216,50 @@ func OAuthIntrospect(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 实现令牌内省逻辑
|
||||
// 1. 验证调用者的认证信息
|
||||
// 2. 解析和验证JWT令牌
|
||||
// 3. 返回令牌的元信息
|
||||
tokenString := token
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"active": false, // 临时返回,需要实现实际的内省逻辑
|
||||
// 验证并解析JWT
|
||||
parsed, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, jwt.ErrTokenSignatureInvalid
|
||||
}
|
||||
pub := oauth.GetPublicKeyByKid(func() string {
|
||||
if v, ok := token.Header["kid"].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}())
|
||||
if pub == nil {
|
||||
return nil, jwt.ErrTokenUnverifiable
|
||||
}
|
||||
return pub, nil
|
||||
})
|
||||
if err != nil || !parsed.Valid {
|
||||
c.JSON(http.StatusOK, gin.H{"active": false})
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
c.JSON(http.StatusOK, gin.H{"active": false})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查撤销
|
||||
if jti, ok := claims["jti"].(string); ok && jti != "" {
|
||||
if revoked, _ := model.IsTokenRevoked(jti); revoked {
|
||||
c.JSON(http.StatusOK, gin.H{"active": false})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 有效
|
||||
resp := gin.H{"active": true}
|
||||
for k, v := range claims {
|
||||
resp[k] = v
|
||||
}
|
||||
resp["token_type"] = "Bearer"
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// OAuthRevoke 令牌撤销端点(RFC 7009)
|
||||
@@ -190,11 +290,86 @@ func OAuthRevoke(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 实现令牌撤销逻辑
|
||||
// 1. 验证调用者的认证信息
|
||||
// 2. 撤销指定的令牌(加入黑名单或从存储中删除)
|
||||
token = c.PostForm("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing token parameter",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
// 尝试解析JWT,若成功则记录jti到撤销表
|
||||
parsed, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, jwt.ErrTokenSignatureInvalid
|
||||
}
|
||||
pub := oauth.GetRSAPublicKey()
|
||||
if pub == nil {
|
||||
return nil, jwt.ErrTokenUnverifiable
|
||||
}
|
||||
return pub, nil
|
||||
})
|
||||
if err == nil && parsed != nil && parsed.Valid {
|
||||
if claims, ok := parsed.Claims.(jwt.MapClaims); ok {
|
||||
var jti string
|
||||
var exp int64
|
||||
if v, ok := claims["jti"].(string); ok {
|
||||
jti = v
|
||||
}
|
||||
if v, ok := claims["exp"].(float64); ok {
|
||||
exp = int64(v)
|
||||
} else if v, ok := claims["exp"].(int64); ok {
|
||||
exp = v
|
||||
}
|
||||
if jti != "" {
|
||||
// 如果没有exp,默认撤销至当前+TTL 10分钟
|
||||
if exp == 0 {
|
||||
exp = time.Now().Add(10 * time.Minute).Unix()
|
||||
}
|
||||
_ = model.RevokeToken(jti, exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// OAuthUserInfo returns OIDC userinfo based on access token
|
||||
func OAuthUserInfo(c *gin.Context) {
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
if !settings.Enabled {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "OAuth2 server is disabled"})
|
||||
return
|
||||
}
|
||||
// 需要 OAuthJWTAuth 中间件注入 claims
|
||||
claims, ok := middleware.GetOAuthClaims(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
|
||||
return
|
||||
}
|
||||
// scope 校验:必须包含 openid
|
||||
scope, _ := claims["scope"].(string)
|
||||
if !strings.Contains(" "+scope+" ", " openid ") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient_scope"})
|
||||
return
|
||||
}
|
||||
sub, _ := claims["sub"].(string)
|
||||
resp := gin.H{"sub": sub}
|
||||
// 若包含 profile/email scope,补充返回
|
||||
if strings.Contains(" "+scope+" ", " profile ") || strings.Contains(" "+scope+" ", " email ") {
|
||||
if uid, err := strconv.Atoi(sub); err == nil {
|
||||
if user, err2 := model.GetUserById(uid, false); err2 == nil && user != nil {
|
||||
if strings.Contains(" "+scope+" ", " profile ") {
|
||||
resp["name"] = user.DisplayName
|
||||
resp["preferred_username"] = user.Username
|
||||
}
|
||||
if strings.Contains(" "+scope+" ", " email ") {
|
||||
resp["email"] = user.Email
|
||||
resp["email_verified"] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
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/setting"
|
||||
"one-api/setting/ratio_setting"
|
||||
"one-api/setting/system_setting"
|
||||
"one-api/src/oauth"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func validUserInfo(username string, role int) bool {
|
||||
@@ -177,6 +180,7 @@ func WssAuth(c *gin.Context) {
|
||||
|
||||
func TokenAuth() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
rawAuth := c.Request.Header.Get("Authorization")
|
||||
// 先检测是否为ws
|
||||
if c.Request.Header.Get("Sec-WebSocket-Protocol") != "" {
|
||||
// Sec-WebSocket-Protocol: realtime, openai-insecure-api-key.sk-xxx, openai-beta.realtime-v1
|
||||
@@ -235,6 +239,11 @@ func TokenAuth() func(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// OAuth Bearer fallback
|
||||
if tryOAuthBearer(c, rawAuth) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -288,6 +297,74 @@ func TokenAuth() func(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// tryOAuthBearer validates an OAuth JWT access token and sets minimal context for relay
|
||||
func tryOAuthBearer(c *gin.Context, rawAuth string) bool {
|
||||
if rawAuth == "" || !strings.HasPrefix(rawAuth, "Bearer ") {
|
||||
return false
|
||||
}
|
||||
tokenString := strings.TrimSpace(strings.TrimPrefix(rawAuth, "Bearer "))
|
||||
if tokenString == "" {
|
||||
return false
|
||||
}
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
// Parse & verify
|
||||
parsed, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, jwt.ErrTokenSignatureInvalid
|
||||
}
|
||||
if kid, ok := t.Header["kid"].(string); ok {
|
||||
if settings.JWTKeyID != "" && kid != settings.JWTKeyID {
|
||||
return nil, jwt.ErrTokenSignatureInvalid
|
||||
}
|
||||
}
|
||||
pub := oauth.GetRSAPublicKey()
|
||||
if pub == nil {
|
||||
return nil, jwt.ErrTokenUnverifiable
|
||||
}
|
||||
return pub, nil
|
||||
})
|
||||
if err != nil || parsed == nil || !parsed.Valid {
|
||||
return false
|
||||
}
|
||||
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// issuer check when configured
|
||||
if iss, ok2 := claims["iss"].(string); !ok2 || (settings.Issuer != "" && iss != settings.Issuer) {
|
||||
return false
|
||||
}
|
||||
// revoke check
|
||||
if jti, ok2 := claims["jti"].(string); ok2 && jti != "" {
|
||||
if revoked, _ := model.IsTokenRevoked(jti); revoked {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// scope check: must contain api:read or api:write or admin
|
||||
scope, _ := claims["scope"].(string)
|
||||
scopePadded := " " + scope + " "
|
||||
if !(strings.Contains(scopePadded, " api:read ") || strings.Contains(scopePadded, " api:write ") || strings.Contains(scopePadded, " admin ")) {
|
||||
return false
|
||||
}
|
||||
// subject must be user id to support quota logic
|
||||
sub, _ := claims["sub"].(string)
|
||||
uid, err := strconv.Atoi(sub)
|
||||
if err != nil || uid <= 0 {
|
||||
return false
|
||||
}
|
||||
// load user cache & set context
|
||||
userCache, err := model.GetUserCache(uid)
|
||||
if err != nil || userCache == nil || userCache.Status != common.UserStatusEnabled {
|
||||
return false
|
||||
}
|
||||
c.Set("id", uid)
|
||||
c.Set("group", userCache.Group)
|
||||
c.Set("user_group", userCache.Group)
|
||||
// set UsingGroup
|
||||
common.SetContextKey(c, constant.ContextKeyUsingGroup, userCache.Group)
|
||||
return true
|
||||
}
|
||||
|
||||
func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) error {
|
||||
if token == nil {
|
||||
return fmt.Errorf("token is nil")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
"one-api/setting/system_setting"
|
||||
"one-api/src/oauth"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -108,23 +109,20 @@ func getPublicKeyByKid(kid string) (*rsa.PublicKey, error) {
|
||||
// 这里先实现一个简单版本
|
||||
|
||||
// TODO: 实现JWKS缓存和刷新机制
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
if settings.JWTKeyID == kid {
|
||||
// 从OAuth server模块获取公钥
|
||||
// 这需要在OAuth server初始化后才能使用
|
||||
return nil, fmt.Errorf("JWKS functionality not yet implemented")
|
||||
pub := oauth.GetPublicKeyByKid(kid)
|
||||
if pub == nil {
|
||||
return nil, fmt.Errorf("unknown kid: %s", kid)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown kid: %s", kid)
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
// validateOAuthClaims 验证OAuth2 claims
|
||||
func validateOAuthClaims(claims jwt.MapClaims) error {
|
||||
settings := system_setting.GetOAuth2Settings()
|
||||
|
||||
// 验证issuer
|
||||
// 验证issuer(若配置了 Issuer 则强校验,否则仅要求存在)
|
||||
if iss, ok := claims["iss"].(string); ok {
|
||||
if iss != settings.Issuer {
|
||||
if settings.Issuer != "" && iss != settings.Issuer {
|
||||
return fmt.Errorf("invalid issuer")
|
||||
}
|
||||
} else {
|
||||
@@ -146,6 +144,14 @@ func validateOAuthClaims(claims jwt.MapClaims) error {
|
||||
if client.Status != common.UserStatusEnabled {
|
||||
return fmt.Errorf("client disabled")
|
||||
}
|
||||
|
||||
// 检查是否被撤销
|
||||
if jti, ok := claims["jti"].(string); ok && jti != "" {
|
||||
revoked, _ := model.IsTokenRevoked(jti)
|
||||
if revoked {
|
||||
return fmt.Errorf("token revoked")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("missing client_id claim")
|
||||
}
|
||||
@@ -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
|
||||
func GetOAuthClaims(c *gin.Context) (jwt.MapClaims, bool) {
|
||||
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
|
||||
apiRouter.GET("/.well-known/jwks.json", controller.GetJWKS)
|
||||
apiRouter.GET("/.well-known/openid-configuration", controller.OAuthOIDCConfiguration)
|
||||
apiRouter.GET("/.well-known/oauth-authorization-server", controller.OAuthServerInfo)
|
||||
apiRouter.POST("/oauth/token", middleware.CriticalRateLimit(), controller.OAuthTokenEndpoint)
|
||||
apiRouter.GET("/oauth/authorize", controller.OAuthAuthorizeEndpoint)
|
||||
apiRouter.POST("/oauth/introspect", middleware.AdminAuth(), controller.OAuthIntrospect)
|
||||
apiRouter.POST("/oauth/revoke", middleware.CriticalRateLimit(), controller.OAuthRevoke)
|
||||
apiRouter.GET("/oauth/userinfo", middleware.OAuthJWTAuth(), controller.OAuthUserInfo)
|
||||
|
||||
// OAuth2 管理API (前端使用)
|
||||
apiRouter.GET("/oauth/jwks", controller.GetJWKS)
|
||||
@@ -53,6 +55,17 @@ func SetApiRouter(router *gin.Engine) {
|
||||
|
||||
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
|
||||
|
||||
// OAuth2 admin operations
|
||||
oauthAdmin := apiRouter.Group("/oauth")
|
||||
oauthAdmin.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.RootAuth())
|
||||
{
|
||||
oauthAdmin.POST("/keys/rotate", controller.RotateOAuthSigningKey)
|
||||
oauthAdmin.GET("/keys", controller.ListOAuthSigningKeys)
|
||||
oauthAdmin.DELETE("/keys/:kid", controller.DeleteOAuthSigningKey)
|
||||
oauthAdmin.POST("/keys/generate_file", controller.GenerateOAuthSigningKeyFile)
|
||||
oauthAdmin.POST("/keys/import_pem", controller.ImportOAuthSigningKey)
|
||||
}
|
||||
|
||||
userRoute := apiRouter.Group("/user")
|
||||
{
|
||||
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
|
||||
@@ -91,7 +104,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
}
|
||||
|
||||
adminRoute := userRoute.Group("/")
|
||||
adminRoute.Use(middleware.AdminAuth())
|
||||
adminRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||
{
|
||||
adminRoute.GET("/", controller.GetAllUsers)
|
||||
adminRoute.GET("/search", controller.SearchUsers)
|
||||
@@ -107,7 +120,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
}
|
||||
}
|
||||
optionRoute := apiRouter.Group("/option")
|
||||
optionRoute.Use(middleware.RootAuth())
|
||||
optionRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.RootAuth())
|
||||
{
|
||||
optionRoute.GET("/", controller.GetOptions)
|
||||
optionRoute.PUT("/", controller.UpdateOption)
|
||||
@@ -121,7 +134,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios)
|
||||
}
|
||||
channelRoute := apiRouter.Group("/channel")
|
||||
channelRoute.Use(middleware.AdminAuth())
|
||||
channelRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||
{
|
||||
channelRoute.GET("/", controller.GetAllChannels)
|
||||
channelRoute.GET("/search", controller.SearchChannels)
|
||||
@@ -172,7 +185,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
}
|
||||
|
||||
redemptionRoute := apiRouter.Group("/redemption")
|
||||
redemptionRoute.Use(middleware.AdminAuth())
|
||||
redemptionRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||
{
|
||||
redemptionRoute.GET("/", controller.GetAllRedemptions)
|
||||
redemptionRoute.GET("/search", controller.SearchRedemptions)
|
||||
@@ -200,13 +213,13 @@ func SetApiRouter(router *gin.Engine) {
|
||||
logRoute.GET("/token", controller.GetLogByKey)
|
||||
}
|
||||
groupRoute := apiRouter.Group("/group")
|
||||
groupRoute.Use(middleware.AdminAuth())
|
||||
groupRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||
{
|
||||
groupRoute.GET("/", controller.GetGroups)
|
||||
}
|
||||
|
||||
prefillGroupRoute := apiRouter.Group("/prefill_group")
|
||||
prefillGroupRoute.Use(middleware.AdminAuth())
|
||||
prefillGroupRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||
{
|
||||
prefillGroupRoute.GET("/", controller.GetPrefillGroups)
|
||||
prefillGroupRoute.POST("/", controller.CreatePrefillGroup)
|
||||
|
||||
@@ -3,19 +3,21 @@ package system_setting
|
||||
import "one-api/setting/config"
|
||||
|
||||
type OAuth2Settings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Issuer string `json:"issuer"`
|
||||
AccessTokenTTL int `json:"access_token_ttl"` // in minutes
|
||||
RefreshTokenTTL int `json:"refresh_token_ttl"` // in minutes
|
||||
AllowedGrantTypes []string `json:"allowed_grant_types"` // client_credentials, authorization_code, refresh_token
|
||||
RequirePKCE bool `json:"require_pkce"` // force PKCE for authorization code flow
|
||||
JWTSigningAlgorithm string `json:"jwt_signing_algorithm"`
|
||||
JWTKeyID string `json:"jwt_key_id"`
|
||||
JWTPrivateKeyFile string `json:"jwt_private_key_file"`
|
||||
AutoCreateUser bool `json:"auto_create_user"` // auto create user on first OAuth2 login
|
||||
DefaultUserRole int `json:"default_user_role"` // default role for auto-created users
|
||||
DefaultUserGroup string `json:"default_user_group"` // default group for auto-created users
|
||||
ScopeMappings map[string][]string `json:"scope_mappings"` // scope to permissions mapping
|
||||
Enabled bool `json:"enabled"`
|
||||
Issuer string `json:"issuer"`
|
||||
AccessTokenTTL int `json:"access_token_ttl"` // in minutes
|
||||
RefreshTokenTTL int `json:"refresh_token_ttl"` // in minutes
|
||||
AllowedGrantTypes []string `json:"allowed_grant_types"` // client_credentials, authorization_code, refresh_token
|
||||
RequirePKCE bool `json:"require_pkce"` // force PKCE for authorization code flow
|
||||
JWTSigningAlgorithm string `json:"jwt_signing_algorithm"`
|
||||
JWTKeyID string `json:"jwt_key_id"`
|
||||
JWTPrivateKeyFile string `json:"jwt_private_key_file"`
|
||||
AutoCreateUser bool `json:"auto_create_user"` // auto create user on first OAuth2 login
|
||||
DefaultUserRole int `json:"default_user_role"` // default role for auto-created users
|
||||
DefaultUserGroup string `json:"default_user_group"` // default group for auto-created users
|
||||
ScopeMappings map[string][]string `json:"scope_mappings"` // scope to permissions mapping
|
||||
MaxJWKSKeys int `json:"max_jwks_keys"` // maximum number of JWKS signing keys to retain
|
||||
DefaultPrivateKeyPath string `json:"default_private_key_path"` // suggested private key file path
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
@@ -35,6 +37,8 @@ var defaultOAuth2Settings = OAuth2Settings{
|
||||
"api:write": {"write"},
|
||||
"admin": {"admin"},
|
||||
},
|
||||
MaxJWKSKeys: 3,
|
||||
DefaultPrivateKeyPath: "/etc/new-api/oauth2-private.pem",
|
||||
}
|
||||
|
||||
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 Playground from './pages/Playground';
|
||||
import OAuth2Callback from './components/auth/OAuth2Callback';
|
||||
import OAuthConsent from './pages/OAuth/Consent';
|
||||
import PersonalSetting from './components/settings/PersonalSetting';
|
||||
import Setup from './pages/Setup';
|
||||
import SetupCheck from './components/layout/SetupCheck';
|
||||
@@ -198,6 +199,14 @@ function App() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/oauth/consent'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<OAuthConsent />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/oauth/linuxdo'
|
||||
element={
|
||||
|
||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
@@ -40,17 +40,128 @@ const { Option } = Select;
|
||||
const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [redirectUris, setRedirectUris] = useState(['']);
|
||||
const [redirectUris, setRedirectUris] = useState([]);
|
||||
const [clientType, setClientType] = useState('confidential');
|
||||
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) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 过滤空的重定向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 = {
|
||||
...values,
|
||||
client_type: clientType,
|
||||
@@ -118,8 +229,8 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||
formApi.reset();
|
||||
}
|
||||
setClientType('confidential');
|
||||
setGrantTypes(['client_credentials']);
|
||||
setRedirectUris(['']);
|
||||
setGrantTypes(computeDefaultGrantTypes('confidential', allowedGrantTypes));
|
||||
setRedirectUris([]);
|
||||
};
|
||||
|
||||
// 处理取消
|
||||
@@ -149,9 +260,13 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||
const handleGrantTypesChange = (values) => {
|
||||
setGrantTypes(values);
|
||||
// 如果包含authorization_code但没有重定向URI,则添加一个
|
||||
if (values.includes('authorization_code') && redirectUris.length === 1 && !redirectUris[0]) {
|
||||
if (values.includes('authorization_code') && redirectUris.length === 0) {
|
||||
setRedirectUris(['']);
|
||||
}
|
||||
// 公开客户端不允许client_credentials
|
||||
if (clientType === 'public' && values.includes('client_credentials')) {
|
||||
setGrantTypes(values.filter((v) => v !== 'client_credentials'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -159,7 +274,7 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||
title="创建OAuth2客户端"
|
||||
visible={visible}
|
||||
onCancel={handleCancel}
|
||||
onOk={() => formApi?.submit()}
|
||||
onOk={() => formApi?.submitForm()}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
confirmLoading={loading}
|
||||
@@ -168,6 +283,12 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||
>
|
||||
<Form
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
initValues={{
|
||||
// 表单默认值优化:预置 OIDC 常用 scope
|
||||
scopes: ['openid', 'profile', 'email', 'api:read'],
|
||||
require_pkce: true,
|
||||
grant_types: grantTypes,
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
labelPosition="top"
|
||||
>
|
||||
@@ -237,9 +358,15 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||
onChange={handleGrantTypesChange}
|
||||
rules={[{ required: true, message: '请选择至少一种授权类型' }]}
|
||||
>
|
||||
<Option value="client_credentials">Client Credentials(客户端凭证)</Option>
|
||||
<Option value="authorization_code">Authorization Code(授权码)</Option>
|
||||
<Option value="refresh_token">Refresh Token(刷新令牌)</Option>
|
||||
<Option value="client_credentials" disabled={isGrantTypeDisabled('client_credentials')}>
|
||||
Client Credentials(客户端凭证)
|
||||
</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>
|
||||
|
||||
{/* Scope */}
|
||||
@@ -247,9 +374,11 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||
field="scopes"
|
||||
label="允许的权限范围(Scope)"
|
||||
multiple
|
||||
defaultValue={['api:read']}
|
||||
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:write">api:write(写入API)</Option>
|
||||
<Option value="admin">admin(管理员权限)</Option>
|
||||
@@ -259,20 +388,19 @@ const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => {
|
||||
<Form.Switch
|
||||
field="require_pkce"
|
||||
label="强制PKCE验证"
|
||||
defaultChecked={true}
|
||||
/>
|
||||
<Paragraph type="tertiary" size="small" style={{ marginTop: -8, marginBottom: 16 }}>
|
||||
PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。
|
||||
</Paragraph>
|
||||
|
||||
{/* 重定向URI */}
|
||||
{grantTypes.includes('authorization_code') && (
|
||||
{(grantTypes.includes('authorization_code') || redirectUris.length > 0) && (
|
||||
<>
|
||||
<Divider>重定向URI配置</Divider>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text strong>重定向URI</Text>
|
||||
<Paragraph type="tertiary" size="small">
|
||||
用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP)。
|
||||
用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。
|
||||
</Paragraph>
|
||||
|
||||
<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 [formApi, setFormApi] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [redirectUris, setRedirectUris] = useState(['']);
|
||||
const [redirectUris, setRedirectUris] = useState([]);
|
||||
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(() => {
|
||||
@@ -60,9 +91,12 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
||||
} else if (Array.isArray(client.scopes)) {
|
||||
parsedScopes = client.scopes;
|
||||
}
|
||||
if (!parsedScopes || parsedScopes.length === 0) {
|
||||
parsedScopes = ['openid', 'profile', 'email', 'api:read'];
|
||||
}
|
||||
|
||||
// 解析重定向URI
|
||||
let parsedRedirectUris = [''];
|
||||
let parsedRedirectUris = [];
|
||||
if (client.redirect_uris) {
|
||||
try {
|
||||
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 = {
|
||||
@@ -87,7 +133,7 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
||||
client_type: client.client_type,
|
||||
grant_types: parsedGrantTypes,
|
||||
scopes: parsedScopes,
|
||||
require_pkce: client.require_pkce,
|
||||
require_pkce: !!client.require_pkce,
|
||||
status: client.status,
|
||||
};
|
||||
if (formApi) {
|
||||
@@ -101,7 +147,57 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 过滤空的重定向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 = {
|
||||
...values,
|
||||
@@ -146,9 +242,13 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
||||
const handleGrantTypesChange = (values) => {
|
||||
setGrantTypes(values);
|
||||
// 如果包含authorization_code但没有重定向URI,则添加一个
|
||||
if (values.includes('authorization_code') && redirectUris.length === 1 && !redirectUris[0]) {
|
||||
if (values.includes('authorization_code') && redirectUris.length === 0) {
|
||||
setRedirectUris(['']);
|
||||
}
|
||||
// 公开客户端不允许client_credentials
|
||||
if (client?.client_type === 'public' && values.includes('client_credentials')) {
|
||||
setGrantTypes(values.filter((v) => v !== 'client_credentials'));
|
||||
}
|
||||
};
|
||||
|
||||
if (!client) return null;
|
||||
@@ -158,7 +258,7 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
||||
title={`编辑OAuth2客户端 - ${client.name}`}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={() => formApi?.submit()}
|
||||
onOk={() => formApi?.submitForm()}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={loading}
|
||||
@@ -217,9 +317,17 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
||||
onChange={handleGrantTypesChange}
|
||||
rules={[{ required: true, message: '请选择至少一种授权类型' }]}
|
||||
>
|
||||
<Option value="client_credentials">Client Credentials(客户端凭证)</Option>
|
||||
<Option value="authorization_code">Authorization Code(授权码)</Option>
|
||||
<Option value="refresh_token">Refresh Token(刷新令牌)</Option>
|
||||
<Option value="client_credentials" disabled={
|
||||
client?.client_type === 'public' || !allowedGrantTypes.includes('client_credentials')
|
||||
}>
|
||||
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>
|
||||
|
||||
{/* Scope */}
|
||||
@@ -229,6 +337,9 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
||||
multiple
|
||||
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:write">api:write(写入API)</Option>
|
||||
<Option value="admin">admin(管理员权限)</Option>
|
||||
@@ -254,13 +365,13 @@ const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
|
||||
</Form.Select>
|
||||
|
||||
{/* 重定向URI */}
|
||||
{grantTypes.includes('authorization_code') && (
|
||||
{(grantTypes.includes('authorization_code') || redirectUris.length > 0) && (
|
||||
<>
|
||||
<Divider>重定向URI配置</Divider>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text strong>重定向URI</Text>
|
||||
<Paragraph type="tertiary" size="small">
|
||||
用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP)。
|
||||
用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP,仅限localhost/127.0.0.1)。
|
||||
</Paragraph>
|
||||
|
||||
<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 { Card, Spin } from '@douyinfe/semi-ui';
|
||||
import { API, showError, toBoolean } from '../../helpers';
|
||||
import { Card, Spin, Space, Button } from '@douyinfe/semi-ui';
|
||||
import { API, showError } from '../../helpers';
|
||||
import OAuth2ServerSettings from '../../pages/Setting/OAuth2/OAuth2ServerSettings';
|
||||
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 [inputs, setInputs] = useState({
|
||||
'oauth2.enabled': false,
|
||||
'oauth2.issuer': '',
|
||||
'oauth2.access_token_ttl': 10,
|
||||
'oauth2.refresh_token_ttl': 720,
|
||||
'oauth2.jwt_signing_algorithm': 'RS256',
|
||||
'oauth2.jwt_key_id': 'oauth2-key-1',
|
||||
'oauth2.jwt_private_key_file': '',
|
||||
'oauth2.allowed_grant_types': ['client_credentials', 'authorization_code'],
|
||||
'oauth2.require_pkce': true,
|
||||
'oauth2.auto_create_user': false,
|
||||
'oauth2.default_user_role': 1,
|
||||
'oauth2.default_user_group': 'default',
|
||||
});
|
||||
// 原样保存后端 Option 键值(字符串),避免类型转换造成子组件解析错误
|
||||
const [options, setOptions] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const getOptions = async () => {
|
||||
@@ -46,25 +38,11 @@ const OAuth2Setting = () => {
|
||||
const res = await API.get('/api/option/');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
data.forEach((item) => {
|
||||
if (Object.keys(inputs).includes(item.key)) {
|
||||
if (item.key === 'oauth2.allowed_grant_types') {
|
||||
try {
|
||||
newInputs[item.key] = JSON.parse(item.value || '["client_credentials","authorization_code"]');
|
||||
} catch {
|
||||
newInputs[item.key] = ['client_credentials', 'authorization_code'];
|
||||
}
|
||||
} else if (typeof inputs[item.key] === 'boolean') {
|
||||
newInputs[item.key] = toBoolean(item.value);
|
||||
} else if (typeof inputs[item.key] === 'number') {
|
||||
newInputs[item.key] = parseInt(item.value) || inputs[item.key];
|
||||
} else {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
setInputs({...inputs, ...newInputs});
|
||||
const map = {};
|
||||
for (const item of data) {
|
||||
map[item.key] = item.value;
|
||||
}
|
||||
setOptions(map);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -83,6 +61,10 @@ const OAuth2Setting = () => {
|
||||
getOptions();
|
||||
}, []);
|
||||
|
||||
const [qsVisible, setQsVisible] = useState(false);
|
||||
const [jwksVisible, setJwksVisible] = useState(false);
|
||||
const [toolsVisible, setToolsVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -92,10 +74,21 @@ const OAuth2Setting = () => {
|
||||
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 />
|
||||
</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>
|
||||
{record.client_type === 'confidential' && (
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="secondary"
|
||||
size="small"
|
||||
onClick={() => handleRegenerateSecret(record)}
|
||||
<Popconfirm
|
||||
title="确认重新生成客户端密钥?"
|
||||
content={
|
||||
<div>
|
||||
<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
|
||||
title="确定删除这个OAuth2客户端吗?"
|
||||
content="删除后无法恢复,相关的API访问将失效。"
|
||||
title="请再次确认删除该客户端"
|
||||
content={
|
||||
<div>
|
||||
<div>客户端:{record.name}</div>
|
||||
<div style={{ marginTop: 6, color: 'var(--semi-color-danger)' }}>删除后无法恢复,相关 API 调用将立即失效。</div>
|
||||
</div>
|
||||
}
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="确定"
|
||||
okText="确定删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
@@ -417,4 +434,4 @@ export default function OAuth2ClientSettings() {
|
||||
</Modal>
|
||||
</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_key_id': 'oauth2-key-1',
|
||||
'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.auto_create_user': false,
|
||||
'oauth2.default_user_role': 1,
|
||||
'oauth2.default_user_group': 'default',
|
||||
'oauth2.max_jwks_keys': 3,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
const [keysReady, setKeysReady] = useState(true);
|
||||
const [keysLoading, setKeysLoading] = useState(false);
|
||||
|
||||
function handleFieldChange(fieldName) {
|
||||
return (value) => {
|
||||
@@ -97,10 +100,11 @@ export default function OAuth2ServerSettings(props) {
|
||||
const testOAuth2 = async () => {
|
||||
try {
|
||||
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服务器运行正常');
|
||||
} else {
|
||||
showError('OAuth2服务器测试失败: ' + res.data.message);
|
||||
showError('OAuth2服务器测试失败');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('OAuth2服务器连接测试失败');
|
||||
@@ -114,9 +118,9 @@ export default function OAuth2ServerSettings(props) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
if (key === 'oauth2.allowed_grant_types') {
|
||||
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 {
|
||||
currentInputs[key] = ['client_credentials', 'authorization_code'];
|
||||
currentInputs[key] = ['client_credentials', 'authorization_code', 'refresh_token'];
|
||||
}
|
||||
} else if (typeof inputs[key] === 'boolean') {
|
||||
currentInputs[key] = props.options[key] === 'true';
|
||||
@@ -135,61 +139,92 @@ export default function OAuth2ServerSettings(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 (
|
||||
<div>
|
||||
<Card>
|
||||
{/* 移除重复的顶卡片,统一在下方“基础配置”中显示开关与 Issuer */}
|
||||
|
||||
{/* 开关与基础配置 */}
|
||||
<Card style={{ marginTop: 10 }}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
>
|
||||
<Form.Section text={'OAuth2 服务器设置'}>
|
||||
<Banner
|
||||
type="info"
|
||||
description={
|
||||
<div>
|
||||
<p>• OAuth2服务器提供标准的API认证和授权功能</p>
|
||||
<p>• 支持Client Credentials、Authorization Code + PKCE等标准流程</p>
|
||||
<p>• 更改配置后需要重启服务才能生效</p>
|
||||
<p>• 生产环境务必配置HTTPS和安全的JWT签名密钥</p>
|
||||
</div>
|
||||
}
|
||||
style={{ marginBottom: 15 }}
|
||||
/>
|
||||
<Form.Section text={'基础配置'}>
|
||||
{!keysReady && inputs['oauth2.enabled'] && (
|
||||
<Banner
|
||||
type='warning'
|
||||
description={<div>
|
||||
<div>尚未准备签名密钥,建议立即初始化或轮换以发布 JWKS。</div>
|
||||
<div>签名密钥用于 JWT 令牌的安全签发。</div>
|
||||
</div>}
|
||||
actions={<Button size='small' onClick={() => props?.onOpenJWKS && props.onOpenJWKS()} loading={keysLoading}>打开密钥向导</Button>}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
<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
|
||||
field='oauth2.enabled'
|
||||
label={t('启用OAuth2服务器')}
|
||||
label={t('启用 OAuth2 & SSO')}
|
||||
checkedText='开'
|
||||
uncheckedText='关'
|
||||
value={inputs['oauth2.enabled']}
|
||||
onChange={handleFieldChange('oauth2.enabled')}
|
||||
extraText="开启后将允许以 OAuth2/OIDC 标准进行授权与登录"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Input
|
||||
field='oauth2.issuer'
|
||||
label={t('签发者标识(Issuer)')}
|
||||
placeholder="https://your-domain.com"
|
||||
extraText="OAuth2令牌的签发者,通常是您的域名"
|
||||
label={t('发行人 (Issuer)')}
|
||||
placeholder={window.location.origin}
|
||||
value={inputs['oauth2.issuer']}
|
||||
onChange={handleFieldChange('oauth2.issuer')}
|
||||
extraText="为空则按请求自动推断(含 X-Forwarded-Proto)"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={onSubmit} loading={loading}>{t('更新服务器设置')}</Button>
|
||||
<Button onClick={onSubmit} loading={loading}>{t('更新基础配置')}</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{inputs['oauth2.enabled'] && (
|
||||
<>
|
||||
<Card style={{ marginTop: 10 }}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
>
|
||||
<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 }}>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<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>
|
||||
</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}>
|
||||
<Form.Input
|
||||
field='oauth2.jwt_private_key_file'
|
||||
label={t('JWT私钥文件路径')}
|
||||
placeholder="/path/to/oauth2-private-key.pem"
|
||||
value={inputs['oauth2.jwt_private_key_file']}
|
||||
onChange={handleFieldChange('oauth2.jwt_private_key_file')}
|
||||
extraText="RSA私钥文件路径,留空将使用内存生成的密钥"
|
||||
<Form.InputNumber
|
||||
field='oauth2.max_jwks_keys'
|
||||
label='JWKS历史保留上限'
|
||||
min={1}
|
||||
max={10}
|
||||
value={inputs['oauth2.max_jwks_keys']}
|
||||
onChange={handleFieldChange('oauth2.max_jwks_keys')}
|
||||
extraText="轮换后最多保留的历史签名密钥数量(越少越安全,建议 3)"
|
||||
/>
|
||||
</Col>
|
||||
</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>
|
||||
</Card>
|
||||
@@ -293,59 +344,61 @@ export default function OAuth2ServerSettings(props) {
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginTop: 10 }}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
>
|
||||
<Form.Section text={'用户配置'}>
|
||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field='oauth2.auto_create_user'
|
||||
label={t('自动创建用户')}
|
||||
checkedText='开'
|
||||
uncheckedText='关'
|
||||
value={inputs['oauth2.auto_create_user']}
|
||||
onChange={handleFieldChange('oauth2.auto_create_user')}
|
||||
extraText="首次OAuth2登录时自动创建用户账户"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Select
|
||||
field='oauth2.default_user_role'
|
||||
label={t('默认用户角色')}
|
||||
value={inputs['oauth2.default_user_role']}
|
||||
onChange={handleFieldChange('oauth2.default_user_role')}
|
||||
extraText="自动创建用户时的默认角色"
|
||||
>
|
||||
<Form.Select.Option value={1}>普通用户</Form.Select.Option>
|
||||
<Form.Select.Option value={10}>管理员</Form.Select.Option>
|
||||
<Form.Select.Option value={100}>超级管理员</Form.Select.Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='oauth2.default_user_group'
|
||||
label={t('默认用户分组')}
|
||||
placeholder="default"
|
||||
value={inputs['oauth2.default_user_group']}
|
||||
onChange={handleFieldChange('oauth2.default_user_group')}
|
||||
extraText="自动创建用户时的默认分组"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={onSubmit} loading={loading}>{t('更新用户配置')}</Button>
|
||||
<Button
|
||||
type="secondary"
|
||||
onClick={testOAuth2}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
{t('测试连接')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Card>
|
||||
{/*<Card style={{ marginTop: 10 }}>*/}
|
||||
{/* <Form*/}
|
||||
{/* initValues={inputs}*/}
|
||||
{/* getFormApi={(formAPI) => (refForm.current = formAPI)}*/}
|
||||
{/* >*/}
|
||||
{/* <Form.Section text={'用户配置'}>*/}
|
||||
{/* <Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>*/}
|
||||
{/* <Col xs={24} sm={24} md={8} lg={8} xl={8}>*/}
|
||||
{/* <Form.Switch*/}
|
||||
{/* field='oauth2.auto_create_user'*/}
|
||||
{/* label={t('自动创建用户')}*/}
|
||||
{/* checkedText='开'*/}
|
||||
{/* uncheckedText='关'*/}
|
||||
{/* value={inputs['oauth2.auto_create_user']}*/}
|
||||
{/* onChange={handleFieldChange('oauth2.auto_create_user')}*/}
|
||||
{/* extraText="首次OAuth2登录时自动创建用户账户"*/}
|
||||
{/* />*/}
|
||||
{/* </Col>*/}
|
||||
{/* <Col xs={24} sm={24} md={8} lg={8} xl={8}>*/}
|
||||
{/* <Form.Select*/}
|
||||
{/* field='oauth2.default_user_role'*/}
|
||||
{/* label={t('默认用户角色')}*/}
|
||||
{/* value={inputs['oauth2.default_user_role']}*/}
|
||||
{/* onChange={handleFieldChange('oauth2.default_user_role')}*/}
|
||||
{/* extraText="自动创建用户时的默认角色"*/}
|
||||
{/* >*/}
|
||||
{/* <Form.Select.Option value={1}>普通用户</Form.Select.Option>*/}
|
||||
{/* <Form.Select.Option value={10}>管理员</Form.Select.Option>*/}
|
||||
{/* <Form.Select.Option value={100}>超级管理员</Form.Select.Option>*/}
|
||||
{/* </Form.Select>*/}
|
||||
{/* </Col>*/}
|
||||
{/* <Col xs={24} sm={24} md={8} lg={8} xl={8}>*/}
|
||||
{/* <Form.Input*/}
|
||||
{/* field='oauth2.default_user_group'*/}
|
||||
{/* label={t('默认用户分组')}*/}
|
||||
{/* placeholder="default"*/}
|
||||
{/* value={inputs['oauth2.default_user_group']}*/}
|
||||
{/* onChange={handleFieldChange('oauth2.default_user_group')}*/}
|
||||
{/* extraText="自动创建用户时的默认分组"*/}
|
||||
{/* />*/}
|
||||
{/* </Col>*/}
|
||||
{/* </Row>*/}
|
||||
{/* <Button onClick={onSubmit} loading={loading}>{t('更新用户配置')}</Button>*/}
|
||||
{/* <Button*/}
|
||||
{/* type="secondary"*/}
|
||||
{/* onClick={testOAuth2}*/}
|
||||
{/* style={{ marginLeft: 8 }}*/}
|
||||
{/* >*/}
|
||||
{/* {t('测试连接')}*/}
|
||||
{/* </Button>*/}
|
||||
{/* </Form.Section>*/}
|
||||
{/* </Form>*/}
|
||||
{/*</Card>*/}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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