feat: oauth2

This commit is contained in:
Seefs
2025-09-16 17:10:01 +08:00
parent 9e6752e0ee
commit 5550ec017e
27 changed files with 4064 additions and 358 deletions

View File

@@ -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
View 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})
}

View 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 ChallengeS256</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 TokenJWT</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>

View 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()
}

View File

@@ -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))
}
}

View File

@@ -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")

View File

@@ -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")

View 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
}

View File

@@ -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)

View File

@@ -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
View 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
View 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
View 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 ChallengeS256</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 TokenJWT</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>

View File

@@ -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={

View File

@@ -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">openidOIDC 基础身份</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 }}>
PKCEProof 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;

View File

@@ -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">openidOIDC 基础身份</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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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;

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}
}

View 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>
);
}

View File

@@ -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 CredentialsAuthorization 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 CredentialsAuthorization 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>
);
}
}

View 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>
);
}