mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 02:05:21 +00:00
feat(oauth): implement custom OAuth provider management #1106
- Add support for custom OAuth providers, including creation, retrieval, updating, and deletion. - Introduce new model and controller for managing custom OAuth providers. - Enhance existing OAuth logic to accommodate custom providers. - Update API routes for custom OAuth provider management. - Include i18n support for custom OAuth-related messages.
This commit is contained in:
386
controller/custom_oauth.go
Normal file
386
controller/custom_oauth.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/oauth"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CustomOAuthProviderResponse is the response structure for custom OAuth providers
|
||||
// It excludes sensitive fields like client_secret
|
||||
type CustomOAuthProviderResponse struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ClientId string `json:"client_id"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserInfoEndpoint string `json:"user_info_endpoint"`
|
||||
Scopes string `json:"scopes"`
|
||||
UserIdField string `json:"user_id_field"`
|
||||
UsernameField string `json:"username_field"`
|
||||
DisplayNameField string `json:"display_name_field"`
|
||||
EmailField string `json:"email_field"`
|
||||
WellKnown string `json:"well_known"`
|
||||
AuthStyle int `json:"auth_style"`
|
||||
}
|
||||
|
||||
func toCustomOAuthProviderResponse(p *model.CustomOAuthProvider) *CustomOAuthProviderResponse {
|
||||
return &CustomOAuthProviderResponse{
|
||||
Id: p.Id,
|
||||
Name: p.Name,
|
||||
Slug: p.Slug,
|
||||
Enabled: p.Enabled,
|
||||
ClientId: p.ClientId,
|
||||
AuthorizationEndpoint: p.AuthorizationEndpoint,
|
||||
TokenEndpoint: p.TokenEndpoint,
|
||||
UserInfoEndpoint: p.UserInfoEndpoint,
|
||||
Scopes: p.Scopes,
|
||||
UserIdField: p.UserIdField,
|
||||
UsernameField: p.UsernameField,
|
||||
DisplayNameField: p.DisplayNameField,
|
||||
EmailField: p.EmailField,
|
||||
WellKnown: p.WellKnown,
|
||||
AuthStyle: p.AuthStyle,
|
||||
}
|
||||
}
|
||||
|
||||
// GetCustomOAuthProviders returns all custom OAuth providers
|
||||
func GetCustomOAuthProviders(c *gin.Context) {
|
||||
providers, err := model.GetAllCustomOAuthProviders()
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response := make([]*CustomOAuthProviderResponse, len(providers))
|
||||
for i, p := range providers {
|
||||
response[i] = toCustomOAuthProviderResponse(p)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": response,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCustomOAuthProvider returns a single custom OAuth provider by ID
|
||||
func GetCustomOAuthProvider(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "无效的 ID")
|
||||
return
|
||||
}
|
||||
|
||||
provider, err := model.GetCustomOAuthProviderById(id)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "未找到该 OAuth 提供商")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": toCustomOAuthProviderResponse(provider),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateCustomOAuthProviderRequest is the request structure for creating a custom OAuth provider
|
||||
type CreateCustomOAuthProviderRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Slug string `json:"slug" binding:"required"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ClientId string `json:"client_id" binding:"required"`
|
||||
ClientSecret string `json:"client_secret" binding:"required"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint" binding:"required"`
|
||||
TokenEndpoint string `json:"token_endpoint" binding:"required"`
|
||||
UserInfoEndpoint string `json:"user_info_endpoint" binding:"required"`
|
||||
Scopes string `json:"scopes"`
|
||||
UserIdField string `json:"user_id_field"`
|
||||
UsernameField string `json:"username_field"`
|
||||
DisplayNameField string `json:"display_name_field"`
|
||||
EmailField string `json:"email_field"`
|
||||
WellKnown string `json:"well_known"`
|
||||
AuthStyle int `json:"auth_style"`
|
||||
}
|
||||
|
||||
// CreateCustomOAuthProvider creates a new custom OAuth provider
|
||||
func CreateCustomOAuthProvider(c *gin.Context) {
|
||||
var req CreateCustomOAuthProviderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "无效的请求参数: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Check if slug is already taken
|
||||
if model.IsSlugTaken(req.Slug, 0) {
|
||||
common.ApiErrorMsg(c, "该 Slug 已被使用")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if slug conflicts with built-in providers
|
||||
if oauth.IsProviderRegistered(req.Slug) && !oauth.IsCustomProvider(req.Slug) {
|
||||
common.ApiErrorMsg(c, "该 Slug 与内置 OAuth 提供商冲突")
|
||||
return
|
||||
}
|
||||
|
||||
provider := &model.CustomOAuthProvider{
|
||||
Name: req.Name,
|
||||
Slug: req.Slug,
|
||||
Enabled: req.Enabled,
|
||||
ClientId: req.ClientId,
|
||||
ClientSecret: req.ClientSecret,
|
||||
AuthorizationEndpoint: req.AuthorizationEndpoint,
|
||||
TokenEndpoint: req.TokenEndpoint,
|
||||
UserInfoEndpoint: req.UserInfoEndpoint,
|
||||
Scopes: req.Scopes,
|
||||
UserIdField: req.UserIdField,
|
||||
UsernameField: req.UsernameField,
|
||||
DisplayNameField: req.DisplayNameField,
|
||||
EmailField: req.EmailField,
|
||||
WellKnown: req.WellKnown,
|
||||
AuthStyle: req.AuthStyle,
|
||||
}
|
||||
|
||||
if err := model.CreateCustomOAuthProvider(provider); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Register the provider in the OAuth registry
|
||||
oauth.RegisterOrUpdateCustomProvider(provider)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "创建成功",
|
||||
"data": toCustomOAuthProviderResponse(provider),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateCustomOAuthProviderRequest is the request structure for updating a custom OAuth provider
|
||||
type UpdateCustomOAuthProviderRequest struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ClientId string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"` // Optional: if empty, keep existing
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserInfoEndpoint string `json:"user_info_endpoint"`
|
||||
Scopes string `json:"scopes"`
|
||||
UserIdField string `json:"user_id_field"`
|
||||
UsernameField string `json:"username_field"`
|
||||
DisplayNameField string `json:"display_name_field"`
|
||||
EmailField string `json:"email_field"`
|
||||
WellKnown string `json:"well_known"`
|
||||
AuthStyle int `json:"auth_style"`
|
||||
}
|
||||
|
||||
// UpdateCustomOAuthProvider updates an existing custom OAuth provider
|
||||
func UpdateCustomOAuthProvider(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "无效的 ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateCustomOAuthProviderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "无效的请求参数: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Get existing provider
|
||||
provider, err := model.GetCustomOAuthProviderById(id)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "未找到该 OAuth 提供商")
|
||||
return
|
||||
}
|
||||
|
||||
oldSlug := provider.Slug
|
||||
|
||||
// Check if new slug is taken by another provider
|
||||
if req.Slug != "" && req.Slug != provider.Slug {
|
||||
if model.IsSlugTaken(req.Slug, id) {
|
||||
common.ApiErrorMsg(c, "该 Slug 已被使用")
|
||||
return
|
||||
}
|
||||
// Check if slug conflicts with built-in providers
|
||||
if oauth.IsProviderRegistered(req.Slug) && !oauth.IsCustomProvider(req.Slug) {
|
||||
common.ApiErrorMsg(c, "该 Slug 与内置 OAuth 提供商冲突")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if req.Name != "" {
|
||||
provider.Name = req.Name
|
||||
}
|
||||
if req.Slug != "" {
|
||||
provider.Slug = req.Slug
|
||||
}
|
||||
provider.Enabled = req.Enabled
|
||||
if req.ClientId != "" {
|
||||
provider.ClientId = req.ClientId
|
||||
}
|
||||
if req.ClientSecret != "" {
|
||||
provider.ClientSecret = req.ClientSecret
|
||||
}
|
||||
if req.AuthorizationEndpoint != "" {
|
||||
provider.AuthorizationEndpoint = req.AuthorizationEndpoint
|
||||
}
|
||||
if req.TokenEndpoint != "" {
|
||||
provider.TokenEndpoint = req.TokenEndpoint
|
||||
}
|
||||
if req.UserInfoEndpoint != "" {
|
||||
provider.UserInfoEndpoint = req.UserInfoEndpoint
|
||||
}
|
||||
if req.Scopes != "" {
|
||||
provider.Scopes = req.Scopes
|
||||
}
|
||||
if req.UserIdField != "" {
|
||||
provider.UserIdField = req.UserIdField
|
||||
}
|
||||
if req.UsernameField != "" {
|
||||
provider.UsernameField = req.UsernameField
|
||||
}
|
||||
if req.DisplayNameField != "" {
|
||||
provider.DisplayNameField = req.DisplayNameField
|
||||
}
|
||||
if req.EmailField != "" {
|
||||
provider.EmailField = req.EmailField
|
||||
}
|
||||
provider.WellKnown = req.WellKnown
|
||||
provider.AuthStyle = req.AuthStyle
|
||||
|
||||
if err := model.UpdateCustomOAuthProvider(provider); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Update the provider in the OAuth registry
|
||||
if oldSlug != provider.Slug {
|
||||
oauth.UnregisterCustomProvider(oldSlug)
|
||||
}
|
||||
oauth.RegisterOrUpdateCustomProvider(provider)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "更新成功",
|
||||
"data": toCustomOAuthProviderResponse(provider),
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteCustomOAuthProvider deletes a custom OAuth provider
|
||||
func DeleteCustomOAuthProvider(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "无效的 ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Get existing provider to get slug
|
||||
provider, err := model.GetCustomOAuthProviderById(id)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "未找到该 OAuth 提供商")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if there are any user bindings
|
||||
count, _ := model.GetBindingCountByProviderId(id)
|
||||
if count > 0 {
|
||||
common.ApiErrorMsg(c, "该 OAuth 提供商还有用户绑定,无法删除。请先解除所有用户绑定。")
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.DeleteCustomOAuthProvider(id); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Unregister the provider from the OAuth registry
|
||||
oauth.UnregisterCustomProvider(provider.Slug)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "删除成功",
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserOAuthBindings returns all OAuth bindings for the current user
|
||||
func GetUserOAuthBindings(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
if userId == 0 {
|
||||
common.ApiErrorMsg(c, "未登录")
|
||||
return
|
||||
}
|
||||
|
||||
bindings, err := model.GetUserOAuthBindingsByUserId(userId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build response with provider info
|
||||
type BindingResponse struct {
|
||||
ProviderId int `json:"provider_id"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
ProviderSlug string `json:"provider_slug"`
|
||||
ProviderUserId string `json:"provider_user_id"`
|
||||
}
|
||||
|
||||
response := make([]BindingResponse, 0)
|
||||
for _, binding := range bindings {
|
||||
provider, err := model.GetCustomOAuthProviderById(binding.ProviderId)
|
||||
if err != nil {
|
||||
continue // Skip if provider not found
|
||||
}
|
||||
response = append(response, BindingResponse{
|
||||
ProviderId: binding.ProviderId,
|
||||
ProviderName: provider.Name,
|
||||
ProviderSlug: provider.Slug,
|
||||
ProviderUserId: binding.ProviderUserId,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": response,
|
||||
})
|
||||
}
|
||||
|
||||
// UnbindCustomOAuth unbinds a custom OAuth provider from the current user
|
||||
func UnbindCustomOAuth(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
if userId == 0 {
|
||||
common.ApiErrorMsg(c, "未登录")
|
||||
return
|
||||
}
|
||||
|
||||
providerIdStr := c.Param("provider_id")
|
||||
providerId, err := strconv.Atoi(providerIdStr)
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "无效的提供商 ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.DeleteUserOAuthBinding(userId, providerId); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "解绑成功",
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/oauth"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/console_setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
@@ -129,6 +130,30 @@ func GetStatus(c *gin.Context) {
|
||||
data["faq"] = console_setting.GetFAQ()
|
||||
}
|
||||
|
||||
// Add enabled custom OAuth providers
|
||||
customProviders := oauth.GetEnabledCustomProviders()
|
||||
if len(customProviders) > 0 {
|
||||
type CustomOAuthInfo struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
ClientId string `json:"client_id"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
Scopes string `json:"scopes"`
|
||||
}
|
||||
providersInfo := make([]CustomOAuthInfo, 0, len(customProviders))
|
||||
for _, p := range customProviders {
|
||||
config := p.GetConfig()
|
||||
providersInfo = append(providersInfo, CustomOAuthInfo{
|
||||
Name: config.Name,
|
||||
Slug: config.Slug,
|
||||
ClientId: config.ClientId,
|
||||
AuthorizationEndpoint: config.AuthorizationEndpoint,
|
||||
Scopes: config.Scopes,
|
||||
})
|
||||
}
|
||||
data["custom_oauth_providers"] = providersInfo
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
|
||||
@@ -171,12 +171,22 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update user with OAuth ID
|
||||
provider.SetProviderUserID(&user, oauthUser.ProviderUserID)
|
||||
err = user.Update(false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
// Handle binding based on provider type
|
||||
if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok {
|
||||
// Custom provider: use user_oauth_bindings table
|
||||
err = model.UpdateUserOAuthBinding(user.Id, genericProvider.GetProviderId(), oauthUser.ProviderUserID)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Built-in provider: update user record directly
|
||||
provider.SetProviderUserID(&user, oauthUser.ProviderUserID)
|
||||
err = user.Update(false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, nil)
|
||||
@@ -188,7 +198,6 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
|
||||
|
||||
// Check if user already exists with new ID
|
||||
if provider.IsUserIDTaken(oauthUser.ProviderUserID) {
|
||||
provider.SetProviderUserID(user, oauthUser.ProviderUserID)
|
||||
err := provider.FillUserByProviderID(user, oauthUser.ProviderUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -203,7 +212,6 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
|
||||
// Try to find user with legacy ID (for GitHub migration from login to numeric ID)
|
||||
if legacyID, ok := oauthUser.Extra["legacy_id"].(string); ok && legacyID != "" {
|
||||
if provider.IsUserIDTaken(legacyID) {
|
||||
provider.SetProviderUserID(user, legacyID)
|
||||
err := provider.FillUserByProviderID(user, legacyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -240,7 +248,6 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
|
||||
}
|
||||
user.Role = common.RoleCommonUser
|
||||
user.Status = common.UserStatusEnabled
|
||||
provider.SetProviderUserID(user, oauthUser.ProviderUserID)
|
||||
|
||||
// Handle affiliate code
|
||||
affCode := session.Get("aff")
|
||||
@@ -253,6 +260,25 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// For custom providers, create the binding after user is created
|
||||
if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok {
|
||||
binding := &model.UserOAuthBinding{
|
||||
UserId: user.Id,
|
||||
ProviderId: genericProvider.GetProviderId(),
|
||||
ProviderUserId: oauthUser.ProviderUserID,
|
||||
}
|
||||
if err := model.CreateUserOAuthBinding(binding); err != nil {
|
||||
common.SysError(fmt.Sprintf("[OAuth] Failed to create binding for user %d: %s", user.Id, err.Error()))
|
||||
// Don't fail the registration, just log the error
|
||||
}
|
||||
} else {
|
||||
// Built-in provider: set the provider user ID on the user model
|
||||
provider.SetProviderUserID(user, oauthUser.ProviderUserID)
|
||||
if err := user.Update(false); err != nil {
|
||||
common.SysError(fmt.Sprintf("[OAuth] Failed to update provider ID for user %d: %s", user.Id, err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
|
||||
11
i18n/keys.go
11
i18n/keys.go
@@ -287,3 +287,14 @@ const (
|
||||
MsgUuidDuplicate = "common.uuid_duplicate"
|
||||
MsgInvalidInput = "common.invalid_input"
|
||||
)
|
||||
|
||||
// Custom OAuth provider related messages
|
||||
const (
|
||||
MsgCustomOAuthNotFound = "custom_oauth.not_found"
|
||||
MsgCustomOAuthSlugEmpty = "custom_oauth.slug_empty"
|
||||
MsgCustomOAuthSlugExists = "custom_oauth.slug_exists"
|
||||
MsgCustomOAuthNameEmpty = "custom_oauth.name_empty"
|
||||
MsgCustomOAuthHasBindings = "custom_oauth.has_bindings"
|
||||
MsgCustomOAuthBindingNotFound = "custom_oauth.binding_not_found"
|
||||
MsgCustomOAuthProviderIdInvalid = "custom_oauth.provider_id_field_invalid"
|
||||
)
|
||||
|
||||
@@ -240,3 +240,12 @@ redeem.failed: "Redemption failed, please try again later"
|
||||
user.create_default_token_error: "Failed to create default token"
|
||||
common.uuid_duplicate: "Please retry, the system generated a duplicate UUID!"
|
||||
common.invalid_input: "Invalid input"
|
||||
|
||||
# Custom OAuth provider messages
|
||||
custom_oauth.not_found: "Custom OAuth provider not found"
|
||||
custom_oauth.slug_empty: "Slug cannot be empty"
|
||||
custom_oauth.slug_exists: "Slug already exists"
|
||||
custom_oauth.name_empty: "Provider name cannot be empty"
|
||||
custom_oauth.has_bindings: "Cannot delete provider with existing user bindings"
|
||||
custom_oauth.binding_not_found: "OAuth binding not found"
|
||||
custom_oauth.provider_id_field_invalid: "Could not extract user ID from provider response"
|
||||
|
||||
@@ -241,3 +241,12 @@ redeem.failed: "兑换失败,请稍后重试"
|
||||
user.create_default_token_error: "创建默认令牌失败"
|
||||
common.uuid_duplicate: "请重试,系统生成的 UUID 竟然重复了!"
|
||||
common.invalid_input: "输入不合法"
|
||||
|
||||
# Custom OAuth provider messages
|
||||
custom_oauth.not_found: "自定义 OAuth 提供商不存在"
|
||||
custom_oauth.slug_empty: "标识符不能为空"
|
||||
custom_oauth.slug_exists: "标识符已存在"
|
||||
custom_oauth.name_empty: "提供商名称不能为空"
|
||||
custom_oauth.has_bindings: "无法删除已有用户绑定的提供商"
|
||||
custom_oauth.binding_not_found: "OAuth 绑定不存在"
|
||||
custom_oauth.provider_id_field_invalid: "无法从提供商响应中提取用户 ID"
|
||||
|
||||
8
main.go
8
main.go
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/oauth"
|
||||
"github.com/QuantumNous/new-api/router"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
_ "github.com/QuantumNous/new-api/setting/performance_setting"
|
||||
@@ -291,5 +292,12 @@ func InitResources() error {
|
||||
// Register user language loader for lazy loading
|
||||
i18n.SetUserLangLoader(model.GetUserLanguage)
|
||||
|
||||
// Load custom OAuth providers from database
|
||||
err = oauth.LoadCustomProviders()
|
||||
if err != nil {
|
||||
common.SysError("failed to load custom OAuth providers: " + err.Error())
|
||||
// Don't return error, custom OAuth is not critical
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
158
model/custom_oauth_provider.go
Normal file
158
model/custom_oauth_provider.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CustomOAuthProvider stores configuration for custom OAuth providers
|
||||
type CustomOAuthProvider struct {
|
||||
Id int `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"type:varchar(64);not null"` // Display name, e.g., "GitHub Enterprise"
|
||||
Slug string `json:"slug" gorm:"type:varchar(64);uniqueIndex;not null"` // URL identifier, e.g., "github-enterprise"
|
||||
Enabled bool `json:"enabled" gorm:"default:false"` // Whether this provider is enabled
|
||||
ClientId string `json:"client_id" gorm:"type:varchar(256)"` // OAuth client ID
|
||||
ClientSecret string `json:"-" gorm:"type:varchar(512)"` // OAuth client secret (not returned to frontend)
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint" gorm:"type:varchar(512)"` // Authorization URL
|
||||
TokenEndpoint string `json:"token_endpoint" gorm:"type:varchar(512)"` // Token exchange URL
|
||||
UserInfoEndpoint string `json:"user_info_endpoint" gorm:"type:varchar(512)"` // User info URL
|
||||
Scopes string `json:"scopes" gorm:"type:varchar(256);default:'openid profile email'"` // OAuth scopes
|
||||
|
||||
// Field mapping configuration (supports JSONPath via gjson)
|
||||
UserIdField string `json:"user_id_field" gorm:"type:varchar(128);default:'sub'"` // User ID field path, e.g., "sub", "id", "data.user.id"
|
||||
UsernameField string `json:"username_field" gorm:"type:varchar(128);default:'preferred_username'"` // Username field path
|
||||
DisplayNameField string `json:"display_name_field" gorm:"type:varchar(128);default:'name'"` // Display name field path
|
||||
EmailField string `json:"email_field" gorm:"type:varchar(128);default:'email'"` // Email field path
|
||||
|
||||
// Advanced options
|
||||
WellKnown string `json:"well_known" gorm:"type:varchar(512)"` // OIDC discovery endpoint (optional)
|
||||
AuthStyle int `json:"auth_style" gorm:"default:0"` // 0=auto, 1=params, 2=header (Basic Auth)
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (CustomOAuthProvider) TableName() string {
|
||||
return "custom_oauth_providers"
|
||||
}
|
||||
|
||||
// GetAllCustomOAuthProviders returns all custom OAuth providers
|
||||
func GetAllCustomOAuthProviders() ([]*CustomOAuthProvider, error) {
|
||||
var providers []*CustomOAuthProvider
|
||||
err := DB.Order("id asc").Find(&providers).Error
|
||||
return providers, err
|
||||
}
|
||||
|
||||
// GetEnabledCustomOAuthProviders returns all enabled custom OAuth providers
|
||||
func GetEnabledCustomOAuthProviders() ([]*CustomOAuthProvider, error) {
|
||||
var providers []*CustomOAuthProvider
|
||||
err := DB.Where("enabled = ?", true).Order("id asc").Find(&providers).Error
|
||||
return providers, err
|
||||
}
|
||||
|
||||
// GetCustomOAuthProviderById returns a custom OAuth provider by ID
|
||||
func GetCustomOAuthProviderById(id int) (*CustomOAuthProvider, error) {
|
||||
var provider CustomOAuthProvider
|
||||
err := DB.First(&provider, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &provider, nil
|
||||
}
|
||||
|
||||
// GetCustomOAuthProviderBySlug returns a custom OAuth provider by slug
|
||||
func GetCustomOAuthProviderBySlug(slug string) (*CustomOAuthProvider, error) {
|
||||
var provider CustomOAuthProvider
|
||||
err := DB.Where("slug = ?", slug).First(&provider).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &provider, nil
|
||||
}
|
||||
|
||||
// CreateCustomOAuthProvider creates a new custom OAuth provider
|
||||
func CreateCustomOAuthProvider(provider *CustomOAuthProvider) error {
|
||||
if err := validateCustomOAuthProvider(provider); err != nil {
|
||||
return err
|
||||
}
|
||||
return DB.Create(provider).Error
|
||||
}
|
||||
|
||||
// UpdateCustomOAuthProvider updates an existing custom OAuth provider
|
||||
func UpdateCustomOAuthProvider(provider *CustomOAuthProvider) error {
|
||||
if err := validateCustomOAuthProvider(provider); err != nil {
|
||||
return err
|
||||
}
|
||||
return DB.Save(provider).Error
|
||||
}
|
||||
|
||||
// DeleteCustomOAuthProvider deletes a custom OAuth provider by ID
|
||||
func DeleteCustomOAuthProvider(id int) error {
|
||||
// First, delete all user bindings for this provider
|
||||
if err := DB.Where("provider_id = ?", id).Delete(&UserOAuthBinding{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return DB.Delete(&CustomOAuthProvider{}, id).Error
|
||||
}
|
||||
|
||||
// IsSlugTaken checks if a slug is already taken by another provider
|
||||
func IsSlugTaken(slug string, excludeId int) bool {
|
||||
var count int64
|
||||
query := DB.Model(&CustomOAuthProvider{}).Where("slug = ?", slug)
|
||||
if excludeId > 0 {
|
||||
query = query.Where("id != ?", excludeId)
|
||||
}
|
||||
query.Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// validateCustomOAuthProvider validates a custom OAuth provider configuration
|
||||
func validateCustomOAuthProvider(provider *CustomOAuthProvider) error {
|
||||
if provider.Name == "" {
|
||||
return errors.New("provider name is required")
|
||||
}
|
||||
if provider.Slug == "" {
|
||||
return errors.New("provider slug is required")
|
||||
}
|
||||
// Slug must be lowercase and contain only alphanumeric characters and hyphens
|
||||
slug := strings.ToLower(provider.Slug)
|
||||
for _, c := range slug {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
|
||||
return errors.New("provider slug must contain only lowercase letters, numbers, and hyphens")
|
||||
}
|
||||
}
|
||||
provider.Slug = slug
|
||||
|
||||
if provider.ClientId == "" {
|
||||
return errors.New("client ID is required")
|
||||
}
|
||||
if provider.AuthorizationEndpoint == "" {
|
||||
return errors.New("authorization endpoint is required")
|
||||
}
|
||||
if provider.TokenEndpoint == "" {
|
||||
return errors.New("token endpoint is required")
|
||||
}
|
||||
if provider.UserInfoEndpoint == "" {
|
||||
return errors.New("user info endpoint is required")
|
||||
}
|
||||
|
||||
// Set defaults for field mappings if empty
|
||||
if provider.UserIdField == "" {
|
||||
provider.UserIdField = "sub"
|
||||
}
|
||||
if provider.UsernameField == "" {
|
||||
provider.UsernameField = "preferred_username"
|
||||
}
|
||||
if provider.DisplayNameField == "" {
|
||||
provider.DisplayNameField = "name"
|
||||
}
|
||||
if provider.EmailField == "" {
|
||||
provider.EmailField = "email"
|
||||
}
|
||||
if provider.Scopes == "" {
|
||||
provider.Scopes = "openid profile email"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -274,6 +274,8 @@ func migrateDB() error {
|
||||
&SubscriptionOrder{},
|
||||
&UserSubscription{},
|
||||
&SubscriptionPreConsumeRecord{},
|
||||
&CustomOAuthProvider{},
|
||||
&UserOAuthBinding{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -320,6 +322,8 @@ func migrateDBFast() error {
|
||||
{&SubscriptionOrder{}, "SubscriptionOrder"},
|
||||
{&UserSubscription{}, "UserSubscription"},
|
||||
{&SubscriptionPreConsumeRecord{}, "SubscriptionPreConsumeRecord"},
|
||||
{&CustomOAuthProvider{}, "CustomOAuthProvider"},
|
||||
{&UserOAuthBinding{}, "UserOAuthBinding"},
|
||||
}
|
||||
// 动态计算migration数量,确保errChan缓冲区足够大
|
||||
errChan := make(chan error, len(migrations))
|
||||
|
||||
125
model/user_oauth_binding.go
Normal file
125
model/user_oauth_binding.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UserOAuthBinding stores the binding relationship between users and custom OAuth providers
|
||||
type UserOAuthBinding struct {
|
||||
Id int `json:"id" gorm:"primaryKey"`
|
||||
UserId int `json:"user_id" gorm:"index;not null"` // User ID
|
||||
ProviderId int `json:"provider_id" gorm:"index;not null"` // Custom OAuth provider ID
|
||||
ProviderUserId string `json:"provider_user_id" gorm:"type:varchar(256);not null"` // User ID from OAuth provider
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Composite unique index to prevent duplicate bindings
|
||||
// One OAuth account can only be bound to one user
|
||||
}
|
||||
|
||||
func (UserOAuthBinding) TableName() string {
|
||||
return "user_oauth_bindings"
|
||||
}
|
||||
|
||||
// GetUserOAuthBindingsByUserId returns all OAuth bindings for a user
|
||||
func GetUserOAuthBindingsByUserId(userId int) ([]*UserOAuthBinding, error) {
|
||||
var bindings []*UserOAuthBinding
|
||||
err := DB.Where("user_id = ?", userId).Find(&bindings).Error
|
||||
return bindings, err
|
||||
}
|
||||
|
||||
// GetUserOAuthBinding returns a specific binding for a user and provider
|
||||
func GetUserOAuthBinding(userId, providerId int) (*UserOAuthBinding, error) {
|
||||
var binding UserOAuthBinding
|
||||
err := DB.Where("user_id = ? AND provider_id = ?", userId, providerId).First(&binding).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &binding, nil
|
||||
}
|
||||
|
||||
// GetUserByOAuthBinding finds a user by provider ID and provider user ID
|
||||
func GetUserByOAuthBinding(providerId int, providerUserId string) (*User, error) {
|
||||
var binding UserOAuthBinding
|
||||
err := DB.Where("provider_id = ? AND provider_user_id = ?", providerId, providerUserId).First(&binding).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user User
|
||||
err = DB.First(&user, binding.UserId).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// IsProviderUserIdTaken checks if a provider user ID is already bound to any user
|
||||
func IsProviderUserIdTaken(providerId int, providerUserId string) bool {
|
||||
var count int64
|
||||
DB.Model(&UserOAuthBinding{}).Where("provider_id = ? AND provider_user_id = ?", providerId, providerUserId).Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// CreateUserOAuthBinding creates a new OAuth binding
|
||||
func CreateUserOAuthBinding(binding *UserOAuthBinding) error {
|
||||
if binding.UserId == 0 {
|
||||
return errors.New("user ID is required")
|
||||
}
|
||||
if binding.ProviderId == 0 {
|
||||
return errors.New("provider ID is required")
|
||||
}
|
||||
if binding.ProviderUserId == "" {
|
||||
return errors.New("provider user ID is required")
|
||||
}
|
||||
|
||||
// Check if this provider user ID is already taken
|
||||
if IsProviderUserIdTaken(binding.ProviderId, binding.ProviderUserId) {
|
||||
return errors.New("this OAuth account is already bound to another user")
|
||||
}
|
||||
|
||||
binding.CreatedAt = time.Now()
|
||||
return DB.Create(binding).Error
|
||||
}
|
||||
|
||||
// UpdateUserOAuthBinding updates an existing OAuth binding (e.g., rebind to different OAuth account)
|
||||
func UpdateUserOAuthBinding(userId, providerId int, newProviderUserId string) error {
|
||||
// Check if the new provider user ID is already taken by another user
|
||||
var existingBinding UserOAuthBinding
|
||||
err := DB.Where("provider_id = ? AND provider_user_id = ?", providerId, newProviderUserId).First(&existingBinding).Error
|
||||
if err == nil && existingBinding.UserId != userId {
|
||||
return errors.New("this OAuth account is already bound to another user")
|
||||
}
|
||||
|
||||
// Check if user already has a binding for this provider
|
||||
var binding UserOAuthBinding
|
||||
err = DB.Where("user_id = ? AND provider_id = ?", userId, providerId).First(&binding).Error
|
||||
if err != nil {
|
||||
// No existing binding, create new one
|
||||
return CreateUserOAuthBinding(&UserOAuthBinding{
|
||||
UserId: userId,
|
||||
ProviderId: providerId,
|
||||
ProviderUserId: newProviderUserId,
|
||||
})
|
||||
}
|
||||
|
||||
// Update existing binding
|
||||
return DB.Model(&binding).Update("provider_user_id", newProviderUserId).Error
|
||||
}
|
||||
|
||||
// DeleteUserOAuthBinding deletes an OAuth binding
|
||||
func DeleteUserOAuthBinding(userId, providerId int) error {
|
||||
return DB.Where("user_id = ? AND provider_id = ?", userId, providerId).Delete(&UserOAuthBinding{}).Error
|
||||
}
|
||||
|
||||
// DeleteUserOAuthBindingsByUserId deletes all OAuth bindings for a user
|
||||
func DeleteUserOAuthBindingsByUserId(userId int) error {
|
||||
return DB.Where("user_id = ?", userId).Delete(&UserOAuthBinding{}).Error
|
||||
}
|
||||
|
||||
// GetBindingCountByProviderId returns the number of bindings for a provider
|
||||
func GetBindingCountByProviderId(providerId int) (int64, error) {
|
||||
var count int64
|
||||
err := DB.Model(&UserOAuthBinding{}).Where("provider_id = ?", providerId).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
268
oauth/generic.go
Normal file
268
oauth/generic.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// AuthStyle defines how to send client credentials
|
||||
const (
|
||||
AuthStyleAutoDetect = 0 // Auto-detect based on server response
|
||||
AuthStyleInParams = 1 // Send client_id and client_secret as POST parameters
|
||||
AuthStyleInHeader = 2 // Send as Basic Auth header
|
||||
)
|
||||
|
||||
// GenericOAuthProvider implements OAuth for custom/generic OAuth providers
|
||||
type GenericOAuthProvider struct {
|
||||
config *model.CustomOAuthProvider
|
||||
}
|
||||
|
||||
// NewGenericOAuthProvider creates a new generic OAuth provider from config
|
||||
func NewGenericOAuthProvider(config *model.CustomOAuthProvider) *GenericOAuthProvider {
|
||||
return &GenericOAuthProvider{config: config}
|
||||
}
|
||||
|
||||
func (p *GenericOAuthProvider) GetName() string {
|
||||
return p.config.Name
|
||||
}
|
||||
|
||||
func (p *GenericOAuthProvider) IsEnabled() bool {
|
||||
return p.config.Enabled
|
||||
}
|
||||
|
||||
func (p *GenericOAuthProvider) GetConfig() *model.CustomOAuthProvider {
|
||||
return p.config
|
||||
}
|
||||
|
||||
func (p *GenericOAuthProvider) ExchangeToken(ctx context.Context, code string, c *gin.Context) (*OAuthToken, error) {
|
||||
if code == "" {
|
||||
return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken: code=%s...", p.config.Slug, code[:min(len(code), 10)])
|
||||
|
||||
redirectUri := fmt.Sprintf("%s/oauth/%s", system_setting.ServerAddress, p.config.Slug)
|
||||
values := url.Values{}
|
||||
values.Set("grant_type", "authorization_code")
|
||||
values.Set("code", code)
|
||||
values.Set("redirect_uri", redirectUri)
|
||||
|
||||
// Determine auth style
|
||||
authStyle := p.config.AuthStyle
|
||||
if authStyle == AuthStyleAutoDetect {
|
||||
// Default to params style for most OAuth servers
|
||||
authStyle = AuthStyleInParams
|
||||
}
|
||||
|
||||
var req *http.Request
|
||||
var err error
|
||||
|
||||
if authStyle == AuthStyleInParams {
|
||||
values.Set("client_id", p.config.ClientId)
|
||||
values.Set("client_secret", p.config.ClientSecret)
|
||||
}
|
||||
|
||||
req, err = http.NewRequestWithContext(ctx, "POST", p.config.TokenEndpoint, strings.NewReader(values.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if authStyle == AuthStyleInHeader {
|
||||
// Basic Auth
|
||||
credentials := base64.StdEncoding.EncodeToString([]byte(p.config.ClientId + ":" + p.config.ClientSecret))
|
||||
req.Header.Set("Authorization", "Basic "+credentials)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken: token_endpoint=%s, redirect_uri=%s, auth_style=%d",
|
||||
p.config.Slug, p.config.TokenEndpoint, redirectUri, authStyle)
|
||||
|
||||
client := http.Client{
|
||||
Timeout: 20 * time.Second,
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] ExchangeToken error: %s", p.config.Slug, err.Error()))
|
||||
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthConnectFailed, map[string]any{"Provider": p.config.Name}, err.Error())
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken response status: %d", p.config.Slug, res.StatusCode)
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] ExchangeToken read body error: %s", p.config.Slug, err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bodyStr := string(body)
|
||||
logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken response body: %s", p.config.Slug, bodyStr[:min(len(bodyStr), 500)])
|
||||
|
||||
// Try to parse as JSON first
|
||||
var tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
IDToken string `json:"id_token"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &tokenResponse); err != nil {
|
||||
// Try to parse as URL-encoded (some OAuth servers like GitHub return this format)
|
||||
parsedValues, parseErr := url.ParseQuery(bodyStr)
|
||||
if parseErr != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] ExchangeToken parse error: %s", p.config.Slug, err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
tokenResponse.AccessToken = parsedValues.Get("access_token")
|
||||
tokenResponse.TokenType = parsedValues.Get("token_type")
|
||||
tokenResponse.Scope = parsedValues.Get("scope")
|
||||
}
|
||||
|
||||
if tokenResponse.Error != "" {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] ExchangeToken OAuth error: %s - %s",
|
||||
p.config.Slug, tokenResponse.Error, tokenResponse.ErrorDesc))
|
||||
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": p.config.Name}, tokenResponse.ErrorDesc)
|
||||
}
|
||||
|
||||
if tokenResponse.AccessToken == "" {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] ExchangeToken failed: empty access token", p.config.Slug))
|
||||
return nil, NewOAuthError(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": p.config.Name})
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken success: scope=%s", p.config.Slug, tokenResponse.Scope)
|
||||
|
||||
return &OAuthToken{
|
||||
AccessToken: tokenResponse.AccessToken,
|
||||
TokenType: tokenResponse.TokenType,
|
||||
RefreshToken: tokenResponse.RefreshToken,
|
||||
ExpiresIn: tokenResponse.ExpiresIn,
|
||||
Scope: tokenResponse.Scope,
|
||||
IDToken: tokenResponse.IDToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *GenericOAuthProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*OAuthUser, error) {
|
||||
logger.LogDebug(ctx, "[OAuth-Generic-%s] GetUserInfo: fetching user info from %s", p.config.Slug, p.config.UserInfoEndpoint)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", p.config.UserInfoEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set authorization header
|
||||
tokenType := token.TokenType
|
||||
if tokenType == "" {
|
||||
tokenType = "Bearer"
|
||||
}
|
||||
req.Header.Set("Authorization", fmt.Sprintf("%s %s", tokenType, token.AccessToken))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := http.Client{
|
||||
Timeout: 20 * time.Second,
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] GetUserInfo error: %s", p.config.Slug, err.Error()))
|
||||
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthConnectFailed, map[string]any{"Provider": p.config.Name}, err.Error())
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-Generic-%s] GetUserInfo response status: %d", p.config.Slug, res.StatusCode)
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] GetUserInfo failed: status=%d", p.config.Slug, res.StatusCode))
|
||||
return nil, NewOAuthError(i18n.MsgOAuthGetUserErr, nil)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] GetUserInfo read body error: %s", p.config.Slug, err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bodyStr := string(body)
|
||||
logger.LogDebug(ctx, "[OAuth-Generic-%s] GetUserInfo response body: %s", p.config.Slug, bodyStr[:min(len(bodyStr), 500)])
|
||||
|
||||
// Extract fields using gjson (supports JSONPath-like syntax)
|
||||
userId := gjson.Get(bodyStr, p.config.UserIdField).String()
|
||||
username := gjson.Get(bodyStr, p.config.UsernameField).String()
|
||||
displayName := gjson.Get(bodyStr, p.config.DisplayNameField).String()
|
||||
email := gjson.Get(bodyStr, p.config.EmailField).String()
|
||||
|
||||
// If user ID field returns a number, convert it
|
||||
if userId == "" {
|
||||
// Try to get as number
|
||||
userIdNum := gjson.Get(bodyStr, p.config.UserIdField)
|
||||
if userIdNum.Exists() {
|
||||
userId = userIdNum.Raw
|
||||
// Remove quotes if present
|
||||
userId = strings.Trim(userId, "\"")
|
||||
}
|
||||
}
|
||||
|
||||
if userId == "" {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] GetUserInfo failed: empty user ID (field: %s)", p.config.Slug, p.config.UserIdField))
|
||||
return nil, NewOAuthError(i18n.MsgOAuthUserInfoEmpty, map[string]any{"Provider": p.config.Name})
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-Generic-%s] GetUserInfo success: id=%s, username=%s, name=%s, email=%s",
|
||||
p.config.Slug, userId, username, displayName, email)
|
||||
|
||||
return &OAuthUser{
|
||||
ProviderUserID: userId,
|
||||
Username: username,
|
||||
DisplayName: displayName,
|
||||
Email: email,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *GenericOAuthProvider) IsUserIDTaken(providerUserID string) bool {
|
||||
return model.IsProviderUserIdTaken(p.config.Id, providerUserID)
|
||||
}
|
||||
|
||||
func (p *GenericOAuthProvider) FillUserByProviderID(user *model.User, providerUserID string) error {
|
||||
foundUser, err := model.GetUserByOAuthBinding(p.config.Id, providerUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*user = *foundUser
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *GenericOAuthProvider) SetProviderUserID(user *model.User, providerUserID string) {
|
||||
// For generic providers, we store the binding in user_oauth_bindings table
|
||||
// This is handled separately in the OAuth controller
|
||||
}
|
||||
|
||||
func (p *GenericOAuthProvider) GetProviderPrefix() string {
|
||||
return p.config.Slug + "_"
|
||||
}
|
||||
|
||||
// GetProviderId returns the provider ID for binding purposes
|
||||
func (p *GenericOAuthProvider) GetProviderId() int {
|
||||
return p.config.Id
|
||||
}
|
||||
|
||||
// IsGenericProvider returns true for generic providers
|
||||
func (p *GenericOAuthProvider) IsGenericProvider() bool {
|
||||
return true
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
)
|
||||
|
||||
var (
|
||||
providers = make(map[string]Provider)
|
||||
mu sync.RWMutex
|
||||
// customProviderSlugs tracks which providers are custom (can be unregistered)
|
||||
customProviderSlugs = make(map[string]bool)
|
||||
)
|
||||
|
||||
// Register registers an OAuth provider with the given name
|
||||
@@ -16,6 +22,22 @@ func Register(name string, provider Provider) {
|
||||
providers[name] = provider
|
||||
}
|
||||
|
||||
// RegisterCustom registers a custom OAuth provider (can be unregistered later)
|
||||
func RegisterCustom(name string, provider Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
providers[name] = provider
|
||||
customProviderSlugs[name] = true
|
||||
}
|
||||
|
||||
// Unregister removes a provider from the registry
|
||||
func Unregister(name string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
delete(providers, name)
|
||||
delete(customProviderSlugs, name)
|
||||
}
|
||||
|
||||
// GetProvider returns the OAuth provider for the given name
|
||||
func GetProvider(name string) Provider {
|
||||
mu.RLock()
|
||||
@@ -34,6 +56,21 @@ func GetAllProviders() map[string]Provider {
|
||||
return result
|
||||
}
|
||||
|
||||
// GetEnabledCustomProviders returns all enabled custom OAuth providers
|
||||
func GetEnabledCustomProviders() []*GenericOAuthProvider {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
var result []*GenericOAuthProvider
|
||||
for name, provider := range providers {
|
||||
if customProviderSlugs[name] {
|
||||
if gp, ok := provider.(*GenericOAuthProvider); ok && gp.IsEnabled() {
|
||||
result = append(result, gp)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// IsProviderRegistered checks if a provider is registered
|
||||
func IsProviderRegistered(name string) bool {
|
||||
mu.RLock()
|
||||
@@ -41,3 +78,57 @@ func IsProviderRegistered(name string) bool {
|
||||
_, ok := providers[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsCustomProvider checks if a provider is a custom provider
|
||||
func IsCustomProvider(name string) bool {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return customProviderSlugs[name]
|
||||
}
|
||||
|
||||
// LoadCustomProviders loads all custom OAuth providers from the database
|
||||
func LoadCustomProviders() error {
|
||||
// First, unregister all existing custom providers
|
||||
mu.Lock()
|
||||
for name := range customProviderSlugs {
|
||||
delete(providers, name)
|
||||
}
|
||||
customProviderSlugs = make(map[string]bool)
|
||||
mu.Unlock()
|
||||
|
||||
// Load all custom providers from database
|
||||
customProviders, err := model.GetAllCustomOAuthProviders()
|
||||
if err != nil {
|
||||
common.SysError("Failed to load custom OAuth providers: " + err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Register each custom provider
|
||||
for _, config := range customProviders {
|
||||
provider := NewGenericOAuthProvider(config)
|
||||
RegisterCustom(config.Slug, provider)
|
||||
common.SysLog("Loaded custom OAuth provider: " + config.Name + " (" + config.Slug + ")")
|
||||
}
|
||||
|
||||
common.SysLog(fmt.Sprintf("Loaded %d custom OAuth providers", len(customProviders)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadCustomProviders reloads all custom OAuth providers from the database
|
||||
func ReloadCustomProviders() error {
|
||||
return LoadCustomProviders()
|
||||
}
|
||||
|
||||
// RegisterOrUpdateCustomProvider registers or updates a single custom provider
|
||||
func RegisterOrUpdateCustomProvider(config *model.CustomOAuthProvider) {
|
||||
provider := NewGenericOAuthProvider(config)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
providers[config.Slug] = provider
|
||||
customProviderSlugs[config.Slug] = true
|
||||
}
|
||||
|
||||
// UnregisterCustomProvider unregisters a custom provider by slug
|
||||
func UnregisterCustomProvider(slug string) {
|
||||
Unregister(slug)
|
||||
}
|
||||
|
||||
@@ -102,6 +102,10 @@ func SetApiRouter(router *gin.Engine) {
|
||||
// Check-in routes
|
||||
selfRoute.GET("/checkin", controller.GetCheckinStatus)
|
||||
selfRoute.POST("/checkin", middleware.TurnstileCheck(), controller.DoCheckin)
|
||||
|
||||
// Custom OAuth bindings
|
||||
selfRoute.GET("/oauth/bindings", controller.GetUserOAuthBindings)
|
||||
selfRoute.DELETE("/oauth/bindings/:provider_id", controller.UnbindCustomOAuth)
|
||||
}
|
||||
|
||||
adminRoute := userRoute.Group("/")
|
||||
@@ -166,6 +170,17 @@ func SetApiRouter(router *gin.Engine) {
|
||||
optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio)
|
||||
optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除
|
||||
}
|
||||
|
||||
// Custom OAuth provider management (admin only)
|
||||
customOAuthRoute := apiRouter.Group("/custom-oauth-provider")
|
||||
customOAuthRoute.Use(middleware.RootAuth())
|
||||
{
|
||||
customOAuthRoute.GET("/", controller.GetCustomOAuthProviders)
|
||||
customOAuthRoute.GET("/:id", controller.GetCustomOAuthProvider)
|
||||
customOAuthRoute.POST("/", controller.CreateCustomOAuthProvider)
|
||||
customOAuthRoute.PUT("/:id", controller.UpdateCustomOAuthProvider)
|
||||
customOAuthRoute.DELETE("/:id", controller.DeleteCustomOAuthProvider)
|
||||
}
|
||||
performanceRoute := apiRouter.Group("/performance")
|
||||
performanceRoute.Use(middleware.RootAuth())
|
||||
{
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
onDiscordOAuthClicked,
|
||||
onOIDCClicked,
|
||||
onLinuxDOOAuthClicked,
|
||||
onCustomOAuthClicked,
|
||||
prepareCredentialRequestOptions,
|
||||
buildAssertionResult,
|
||||
isPasskeySupported,
|
||||
@@ -109,6 +110,7 @@ const LoginForm = () => {
|
||||
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false);
|
||||
const githubTimeoutRef = useRef(null);
|
||||
const githubButtonText = t(githubButtonTextKeyByState[githubButtonState]);
|
||||
const [customOAuthLoading, setCustomOAuthLoading] = useState({});
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
@@ -373,6 +375,23 @@ const LoginForm = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 包装的自定义OAuth登录点击处理
|
||||
const handleCustomOAuthClick = (provider) => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: true }));
|
||||
try {
|
||||
onCustomOAuthClicked(provider, { shouldLogout: true });
|
||||
} finally {
|
||||
// 由于重定向,这里不会执行到,但为了完整性添加
|
||||
setTimeout(() => {
|
||||
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: false }));
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// 包装的邮箱登录选项点击处理
|
||||
const handleEmailLoginClick = () => {
|
||||
setEmailLoginLoading(true);
|
||||
@@ -572,6 +591,23 @@ const LoginForm = () => {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.custom_oauth_providers &&
|
||||
status.custom_oauth_providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.slug}
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<IconLock size='large' />}
|
||||
onClick={() => handleCustomOAuthClick(provider)}
|
||||
loading={customOAuthLoading[provider.slug]}
|
||||
>
|
||||
<span className='ml-3'>
|
||||
{t('使用 {{name}} 继续', { name: provider.name })}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{status.telegram_oauth && (
|
||||
<div className='flex justify-center my-2'>
|
||||
<TelegramLoginButton
|
||||
|
||||
631
web/src/components/settings/CustomOAuthSetting.jsx
Normal file
631
web/src/components/settings/CustomOAuthSetting.jsx
Normal file
@@ -0,0 +1,631 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Modal,
|
||||
Banner,
|
||||
Card,
|
||||
Table,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Select,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconPlus, IconEdit, IconDelete } from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// Preset templates for common OAuth providers
|
||||
const OAUTH_PRESETS = {
|
||||
'github-enterprise': {
|
||||
name: 'GitHub Enterprise',
|
||||
authorization_endpoint: '/login/oauth/authorize',
|
||||
token_endpoint: '/login/oauth/access_token',
|
||||
user_info_endpoint: '/api/v3/user',
|
||||
scopes: 'user:email',
|
||||
user_id_field: 'id',
|
||||
username_field: 'login',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
},
|
||||
gitlab: {
|
||||
name: 'GitLab',
|
||||
authorization_endpoint: '/oauth/authorize',
|
||||
token_endpoint: '/oauth/token',
|
||||
user_info_endpoint: '/api/v4/user',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'id',
|
||||
username_field: 'username',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
},
|
||||
gitea: {
|
||||
name: 'Gitea',
|
||||
authorization_endpoint: '/login/oauth/authorize',
|
||||
token_endpoint: '/login/oauth/access_token',
|
||||
user_info_endpoint: '/api/v1/user',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'id',
|
||||
username_field: 'login',
|
||||
display_name_field: 'full_name',
|
||||
email_field: 'email',
|
||||
},
|
||||
nextcloud: {
|
||||
name: 'Nextcloud',
|
||||
authorization_endpoint: '/apps/oauth2/authorize',
|
||||
token_endpoint: '/apps/oauth2/api/v1/token',
|
||||
user_info_endpoint: '/ocs/v2.php/cloud/user?format=json',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'ocs.data.id',
|
||||
username_field: 'ocs.data.id',
|
||||
display_name_field: 'ocs.data.displayname',
|
||||
email_field: 'ocs.data.email',
|
||||
},
|
||||
keycloak: {
|
||||
name: 'Keycloak',
|
||||
authorization_endpoint: '/realms/{realm}/protocol/openid-connect/auth',
|
||||
token_endpoint: '/realms/{realm}/protocol/openid-connect/token',
|
||||
user_info_endpoint: '/realms/{realm}/protocol/openid-connect/userinfo',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'sub',
|
||||
username_field: 'preferred_username',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
},
|
||||
authentik: {
|
||||
name: 'Authentik',
|
||||
authorization_endpoint: '/application/o/authorize/',
|
||||
token_endpoint: '/application/o/token/',
|
||||
user_info_endpoint: '/application/o/userinfo/',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'sub',
|
||||
username_field: 'preferred_username',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
},
|
||||
ory: {
|
||||
name: 'ORY Hydra',
|
||||
authorization_endpoint: '/oauth2/auth',
|
||||
token_endpoint: '/oauth2/token',
|
||||
user_info_endpoint: '/userinfo',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'sub',
|
||||
username_field: 'preferred_username',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
},
|
||||
};
|
||||
|
||||
const CustomOAuthSetting = ({ serverAddress }) => {
|
||||
const { t } = useTranslation();
|
||||
const [providers, setProviders] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState(null);
|
||||
const [formValues, setFormValues] = useState({});
|
||||
const [selectedPreset, setSelectedPreset] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const formApiRef = React.useRef(null);
|
||||
|
||||
const fetchProviders = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/custom-oauth-provider/');
|
||||
if (res.data.success) {
|
||||
setProviders(res.data.data || []);
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('获取自定义 OAuth 提供商列表失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProviders();
|
||||
}, []);
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingProvider(null);
|
||||
setFormValues({
|
||||
enabled: false,
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'sub',
|
||||
username_field: 'preferred_username',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
auth_style: 0,
|
||||
});
|
||||
setSelectedPreset('');
|
||||
setBaseUrl('');
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (provider) => {
|
||||
setEditingProvider(provider);
|
||||
setFormValues({ ...provider });
|
||||
setSelectedPreset('');
|
||||
setBaseUrl('');
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
const res = await API.delete(`/api/custom-oauth-provider/${id}`);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('删除成功'));
|
||||
fetchProviders();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('删除失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validate required fields
|
||||
const requiredFields = [
|
||||
'name',
|
||||
'slug',
|
||||
'client_id',
|
||||
'authorization_endpoint',
|
||||
'token_endpoint',
|
||||
'user_info_endpoint',
|
||||
];
|
||||
|
||||
if (!editingProvider) {
|
||||
requiredFields.push('client_secret');
|
||||
}
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!formValues[field]) {
|
||||
showError(t(`请填写 ${field}`));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate endpoint URLs must be full URLs
|
||||
const endpointFields = ['authorization_endpoint', 'token_endpoint', 'user_info_endpoint'];
|
||||
for (const field of endpointFields) {
|
||||
const value = formValues[field];
|
||||
if (value && !value.startsWith('http://') && !value.startsWith('https://')) {
|
||||
// Check if user selected a preset but forgot to fill server address
|
||||
if (selectedPreset && !baseUrl) {
|
||||
showError(t('请先填写服务器地址,以自动生成完整的端点 URL'));
|
||||
} else {
|
||||
showError(t('端点 URL 必须是完整地址(以 http:// 或 https:// 开头)'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (editingProvider) {
|
||||
res = await API.put(
|
||||
`/api/custom-oauth-provider/${editingProvider.id}`,
|
||||
formValues
|
||||
);
|
||||
} else {
|
||||
res = await API.post('/api/custom-oauth-provider/', formValues);
|
||||
}
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(editingProvider ? t('更新成功') : t('创建成功'));
|
||||
setModalVisible(false);
|
||||
fetchProviders();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(editingProvider ? t('更新失败') : t('创建失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresetChange = (preset) => {
|
||||
setSelectedPreset(preset);
|
||||
if (preset && OAUTH_PRESETS[preset]) {
|
||||
const presetConfig = OAUTH_PRESETS[preset];
|
||||
const cleanUrl = baseUrl ? baseUrl.replace(/\/+$/, '') : '';
|
||||
const newValues = {
|
||||
name: presetConfig.name,
|
||||
slug: preset,
|
||||
scopes: presetConfig.scopes,
|
||||
user_id_field: presetConfig.user_id_field,
|
||||
username_field: presetConfig.username_field,
|
||||
display_name_field: presetConfig.display_name_field,
|
||||
email_field: presetConfig.email_field,
|
||||
auth_style: presetConfig.auth_style ?? 0,
|
||||
};
|
||||
// Only fill endpoints if server address is provided
|
||||
if (cleanUrl) {
|
||||
newValues.authorization_endpoint = cleanUrl + presetConfig.authorization_endpoint;
|
||||
newValues.token_endpoint = cleanUrl + presetConfig.token_endpoint;
|
||||
newValues.user_info_endpoint = cleanUrl + presetConfig.user_info_endpoint;
|
||||
}
|
||||
setFormValues((prev) => ({ ...prev, ...newValues }));
|
||||
// Update form fields directly via formApi
|
||||
if (formApiRef.current) {
|
||||
Object.entries(newValues).forEach(([key, value]) => {
|
||||
formApiRef.current.setValue(key, value);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBaseUrlChange = (url) => {
|
||||
setBaseUrl(url);
|
||||
if (url && selectedPreset && OAUTH_PRESETS[selectedPreset]) {
|
||||
const presetConfig = OAUTH_PRESETS[selectedPreset];
|
||||
const cleanUrl = url.replace(/\/+$/, ''); // Remove trailing slashes
|
||||
const newValues = {
|
||||
authorization_endpoint: cleanUrl + presetConfig.authorization_endpoint,
|
||||
token_endpoint: cleanUrl + presetConfig.token_endpoint,
|
||||
user_info_endpoint: cleanUrl + presetConfig.user_info_endpoint,
|
||||
};
|
||||
setFormValues((prev) => ({ ...prev, ...newValues }));
|
||||
// Update form fields directly via formApi (use merge mode to preserve other fields)
|
||||
if (formApiRef.current) {
|
||||
Object.entries(newValues).forEach(([key, value]) => {
|
||||
formApiRef.current.setValue(key, value);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('名称'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Slug',
|
||||
dataIndex: 'slug',
|
||||
key: 'slug',
|
||||
render: (slug) => <Tag>{slug}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
render: (enabled) => (
|
||||
<Tag color={enabled ? 'green' : 'grey'}>
|
||||
{enabled ? t('已启用') : t('已禁用')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Client ID'),
|
||||
dataIndex: 'client_id',
|
||||
key: 'client_id',
|
||||
render: (id) => (id ? id.substring(0, 20) + '...' : '-'),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'actions',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<IconEdit />}
|
||||
size="small"
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('确定要删除此 OAuth 提供商吗?')}
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button icon={<IconDelete />} size="small" type="danger">
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Form.Section text={t('自定义 OAuth 提供商')}>
|
||||
<Banner
|
||||
type="info"
|
||||
description={
|
||||
<>
|
||||
{t(
|
||||
'配置自定义 OAuth 提供商,支持 GitHub Enterprise、GitLab、Gitea、Nextcloud、Keycloak、ORY 等兼容 OAuth 2.0 协议的身份提供商'
|
||||
)}
|
||||
<br />
|
||||
{t('回调 URL 格式')}: {serverAddress || t('网站地址')}/oauth/
|
||||
{'{slug}'}
|
||||
</>
|
||||
}
|
||||
style={{ marginBottom: 20 }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
theme="solid"
|
||||
onClick={handleAdd}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
{t('添加 OAuth 提供商')}
|
||||
</Button>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={providers}
|
||||
loading={loading}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
empty={t('暂无自定义 OAuth 提供商')}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingProvider ? t('编辑 OAuth 提供商') : t('添加 OAuth 提供商')}
|
||||
visible={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
okText={t('保存')}
|
||||
cancelText={t('取消')}
|
||||
width={800}
|
||||
>
|
||||
<Form
|
||||
initValues={formValues}
|
||||
onValueChange={(values) => setFormValues(values)}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
>
|
||||
{!editingProvider && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field="preset"
|
||||
label={t('预设模板')}
|
||||
placeholder={t('选择预设模板(可选)')}
|
||||
value={selectedPreset}
|
||||
onChange={handlePresetChange}
|
||||
optionList={[
|
||||
{ value: '', label: t('自定义') },
|
||||
...Object.entries(OAUTH_PRESETS).map(([key, config]) => ({
|
||||
value: key,
|
||||
label: config.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="base_url"
|
||||
label={
|
||||
selectedPreset
|
||||
? t('服务器地址') + ' *'
|
||||
: t('服务器地址')
|
||||
}
|
||||
placeholder={t('例如:https://gitea.example.com')}
|
||||
value={baseUrl}
|
||||
onChange={handleBaseUrlChange}
|
||||
extraText={
|
||||
selectedPreset
|
||||
? t('必填:请输入服务器地址以自动生成完整端点 URL')
|
||||
: t('选择预设模板后填写服务器地址可自动填充端点')
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="name"
|
||||
label={t('显示名称')}
|
||||
placeholder={t('例如:GitHub Enterprise')}
|
||||
rules={[{ required: true, message: t('请输入显示名称') }]}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="slug"
|
||||
label="Slug"
|
||||
placeholder={t('例如:github-enterprise')}
|
||||
extraText={t('URL 标识,只能包含小写字母、数字和连字符')}
|
||||
rules={[{ required: true, message: t('请输入 Slug') }]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="client_id"
|
||||
label="Client ID"
|
||||
placeholder={t('OAuth Client ID')}
|
||||
rules={[{ required: true, message: t('请输入 Client ID') }]}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="client_secret"
|
||||
label="Client Secret"
|
||||
type="password"
|
||||
placeholder={
|
||||
editingProvider
|
||||
? t('留空则保持原有密钥')
|
||||
: t('OAuth Client Secret')
|
||||
}
|
||||
rules={
|
||||
editingProvider
|
||||
? []
|
||||
: [{ required: true, message: t('请输入 Client Secret') }]
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
|
||||
{t('OAuth 端点')}
|
||||
</Text>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field="authorization_endpoint"
|
||||
label={t('Authorization Endpoint')}
|
||||
placeholder={
|
||||
selectedPreset && OAUTH_PRESETS[selectedPreset]
|
||||
? t('填写服务器地址后自动生成:') +
|
||||
OAUTH_PRESETS[selectedPreset].authorization_endpoint
|
||||
: 'https://example.com/oauth/authorize'
|
||||
}
|
||||
rules={[
|
||||
{ required: true, message: t('请输入 Authorization Endpoint') },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="token_endpoint"
|
||||
label={t('Token Endpoint')}
|
||||
placeholder={
|
||||
selectedPreset && OAUTH_PRESETS[selectedPreset]
|
||||
? t('自动生成:') + OAUTH_PRESETS[selectedPreset].token_endpoint
|
||||
: 'https://example.com/oauth/token'
|
||||
}
|
||||
rules={[{ required: true, message: t('请输入 Token Endpoint') }]}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="user_info_endpoint"
|
||||
label={t('User Info Endpoint')}
|
||||
placeholder={
|
||||
selectedPreset && OAUTH_PRESETS[selectedPreset]
|
||||
? t('自动生成:') + OAUTH_PRESETS[selectedPreset].user_info_endpoint
|
||||
: 'https://example.com/api/user'
|
||||
}
|
||||
rules={[
|
||||
{ required: true, message: t('请输入 User Info Endpoint') },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="scopes"
|
||||
label={t('Scopes')}
|
||||
placeholder="openid profile email"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="well_known"
|
||||
label={t('Well-Known URL')}
|
||||
placeholder={t('OIDC Discovery 端点(可选)')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
|
||||
{t('字段映射')}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
{t('配置如何从用户信息 API 响应中提取用户数据,支持 JSONPath 语法')}
|
||||
</Text>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="user_id_field"
|
||||
label={t('用户 ID 字段')}
|
||||
placeholder={t('例如:sub、id、data.user.id')}
|
||||
extraText={t('用于唯一标识用户的字段路径')}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="username_field"
|
||||
label={t('用户名字段')}
|
||||
placeholder={t('例如:preferred_username、login')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="display_name_field"
|
||||
label={t('显示名称字段')}
|
||||
placeholder={t('例如:name、full_name')}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="email_field"
|
||||
label={t('邮箱字段')}
|
||||
placeholder={t('例如:email')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
|
||||
{t('高级选项')}
|
||||
</Text>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field="auth_style"
|
||||
label={t('认证方式')}
|
||||
optionList={[
|
||||
{ value: 0, label: t('自动检测') },
|
||||
{ value: 1, label: t('POST 参数') },
|
||||
{ value: 2, label: t('Basic Auth 头') },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Checkbox field="enabled" noLabel>
|
||||
{t('启用此 OAuth 提供商')}
|
||||
</Form.Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomOAuthSetting;
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from '../../helpers';
|
||||
import axios from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CustomOAuthSetting from './CustomOAuthSetting';
|
||||
|
||||
const SystemSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -1534,6 +1535,8 @@ const SystemSetting = () => {
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
<CustomOAuthSetting serverAddress={inputs.ServerAddress} />
|
||||
|
||||
<Card>
|
||||
<Form.Section text={t('配置 WeChat Server')}>
|
||||
<Text>{t('用以支持通过微信进行登录注册')}</Text>
|
||||
|
||||
@@ -42,10 +42,14 @@ import { SiTelegram, SiWechat, SiLinux, SiDiscord } from 'react-icons/si';
|
||||
import { UserPlus, ShieldCheck } from 'lucide-react';
|
||||
import TelegramLoginButton from 'react-telegram-login';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
onGitHubOAuthClicked,
|
||||
onOIDCClicked,
|
||||
onLinuxDOOAuthClicked,
|
||||
onDiscordOAuthClicked,
|
||||
onCustomOAuthClicked,
|
||||
} from '../../../../helpers';
|
||||
import TwoFASetting from '../components/TwoFASetting';
|
||||
|
||||
@@ -94,6 +98,66 @@ const AccountManagement = ({
|
||||
const isBound = (accountId) => Boolean(accountId);
|
||||
const [showTelegramBindModal, setShowTelegramBindModal] =
|
||||
React.useState(false);
|
||||
const [customOAuthBindings, setCustomOAuthBindings] = React.useState([]);
|
||||
const [customOAuthLoading, setCustomOAuthLoading] = React.useState({});
|
||||
|
||||
// Fetch custom OAuth bindings
|
||||
const loadCustomOAuthBindings = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/user/oauth/bindings');
|
||||
if (res.data.success) {
|
||||
setCustomOAuthBindings(res.data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Unbind custom OAuth provider
|
||||
const handleUnbindCustomOAuth = async (providerId, providerName) => {
|
||||
Modal.confirm({
|
||||
title: t('确认解绑'),
|
||||
content: t('确定要解绑 {{name}} 吗?', { name: providerName }),
|
||||
okText: t('确认'),
|
||||
cancelText: t('取消'),
|
||||
onOk: async () => {
|
||||
setCustomOAuthLoading((prev) => ({ ...prev, [providerId]: true }));
|
||||
try {
|
||||
const res = await API.delete(`/api/user/oauth/bindings/${providerId}`);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('解绑成功'));
|
||||
await loadCustomOAuthBindings();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('操作失败'));
|
||||
} finally {
|
||||
setCustomOAuthLoading((prev) => ({ ...prev, [providerId]: false }));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Handle bind custom OAuth
|
||||
const handleBindCustomOAuth = (provider) => {
|
||||
onCustomOAuthClicked(provider);
|
||||
};
|
||||
|
||||
// Check if custom OAuth provider is bound
|
||||
const isCustomOAuthBound = (providerId) => {
|
||||
return customOAuthBindings.some((b) => b.provider_id === providerId);
|
||||
};
|
||||
|
||||
// Get binding info for a provider
|
||||
const getCustomOAuthBinding = (providerId) => {
|
||||
return customOAuthBindings.find((b) => b.provider_id === providerId);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
loadCustomOAuthBindings();
|
||||
}, []);
|
||||
|
||||
const passkeyEnabled = passkeyStatus?.enabled;
|
||||
const lastUsedLabel = passkeyStatus?.last_used_at
|
||||
? new Date(passkeyStatus.last_used_at).toLocaleString()
|
||||
@@ -447,6 +511,64 @@ const AccountManagement = ({
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 自定义 OAuth 提供商绑定 */}
|
||||
{status.custom_oauth_providers &&
|
||||
status.custom_oauth_providers.map((provider) => {
|
||||
const bound = isCustomOAuthBound(provider.id);
|
||||
const binding = getCustomOAuthBinding(provider.id);
|
||||
return (
|
||||
<Card key={provider.slug} className='!rounded-xl'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center flex-1 min-w-0'>
|
||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
||||
<IconLock
|
||||
size='default'
|
||||
className='text-slate-600 dark:text-slate-300'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='font-medium text-gray-900'>
|
||||
{provider.name}
|
||||
</div>
|
||||
<div className='text-sm text-gray-500 truncate'>
|
||||
{bound
|
||||
? renderAccountInfo(
|
||||
binding?.provider_user_id,
|
||||
t('{{name}} ID', { name: provider.name }),
|
||||
)
|
||||
: t('未绑定')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
{bound ? (
|
||||
<Button
|
||||
type='danger'
|
||||
theme='outline'
|
||||
size='small'
|
||||
loading={customOAuthLoading[provider.id]}
|
||||
onClick={() =>
|
||||
handleUnbindCustomOAuth(provider.id, provider.name)
|
||||
}
|
||||
>
|
||||
{t('解绑')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type='primary'
|
||||
theme='outline'
|
||||
size='small'
|
||||
onClick={() => handleBindCustomOAuth(provider)}
|
||||
>
|
||||
{t('绑定')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
@@ -294,6 +294,48 @@ export async function onLinuxDOOAuthClicked(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate custom OAuth login
|
||||
* @param {Object} provider - Custom OAuth provider config from status API
|
||||
* @param {string} provider.slug - Provider slug (used for callback URL)
|
||||
* @param {string} provider.client_id - OAuth client ID
|
||||
* @param {string} provider.authorization_endpoint - Authorization URL
|
||||
* @param {string} provider.scopes - OAuth scopes (space-separated)
|
||||
* @param {Object} options - Options
|
||||
* @param {boolean} options.shouldLogout - Whether to logout first
|
||||
*/
|
||||
export async function onCustomOAuthClicked(provider, options = {}) {
|
||||
const state = await prepareOAuthState(options);
|
||||
if (!state) return;
|
||||
|
||||
try {
|
||||
const redirect_uri = `${window.location.origin}/oauth/${provider.slug}`;
|
||||
|
||||
// Check if authorization_endpoint is a full URL or relative path
|
||||
let authUrl;
|
||||
if (provider.authorization_endpoint.startsWith('http://') ||
|
||||
provider.authorization_endpoint.startsWith('https://')) {
|
||||
authUrl = new URL(provider.authorization_endpoint);
|
||||
} else {
|
||||
// Relative path - this is a configuration error, show error message
|
||||
console.error('Custom OAuth authorization_endpoint must be a full URL:', provider.authorization_endpoint);
|
||||
showError('OAuth 配置错误:授权端点必须是完整的 URL(以 http:// 或 https:// 开头)');
|
||||
return;
|
||||
}
|
||||
|
||||
authUrl.searchParams.set('client_id', provider.client_id);
|
||||
authUrl.searchParams.set('redirect_uri', redirect_uri);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('scope', provider.scopes || 'openid profile email');
|
||||
authUrl.searchParams.set('state', state);
|
||||
|
||||
window.open(authUrl.toString());
|
||||
} catch (error) {
|
||||
console.error('Failed to initiate custom OAuth:', error);
|
||||
showError('OAuth 登录失败:' + (error.message || '未知错误'));
|
||||
}
|
||||
}
|
||||
|
||||
let channelModels = undefined;
|
||||
export async function loadChannelModels() {
|
||||
const res = await API.get('/api/models');
|
||||
|
||||
@@ -2795,6 +2795,49 @@
|
||||
"语言偏好": "Language Preference",
|
||||
"选择您的首选界面语言,设置将自动保存并同步到所有设备": "Select your preferred interface language. Settings will be saved automatically and synced across all devices",
|
||||
"语言偏好已保存": "Language preference saved",
|
||||
"提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。": "Note: Language preference syncs across all your logged-in devices and affects the language of API error messages."
|
||||
"提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。": "Note: Language preference syncs across all your logged-in devices and affects the language of API error messages.",
|
||||
"自定义 OAuth 提供商": "Custom OAuth Providers",
|
||||
"配置自定义 OAuth 提供商,支持 GitHub Enterprise、GitLab、Gitea、Nextcloud、Keycloak、ORY 等兼容 OAuth 2.0 协议的身份提供商": "Configure custom OAuth providers, supports GitHub Enterprise, GitLab, Gitea, Nextcloud, Keycloak, ORY and other OAuth 2.0 compatible identity providers",
|
||||
"回调 URL 格式": "Callback URL format",
|
||||
"添加提供商": "Add Provider",
|
||||
"编辑提供商": "Edit Provider",
|
||||
"选择预设...": "Select preset...",
|
||||
"输入基础 URL": "Enter base URL",
|
||||
"例如": "e.g.",
|
||||
"提供商名称": "Provider Name",
|
||||
"标识符 (Slug)": "Slug",
|
||||
"授权端点": "Authorization Endpoint",
|
||||
"令牌端点": "Token Endpoint",
|
||||
"用户信息端点": "User Info Endpoint",
|
||||
"用户 ID 字段": "User ID Field",
|
||||
"支持 JSONPath,如 sub, id, data.user.id": "Supports JSONPath, e.g. sub, id, data.user.id",
|
||||
"用户名字段": "Username Field",
|
||||
"支持 JSONPath,如 preferred_username, login, data.user.username": "Supports JSONPath, e.g. preferred_username, login, data.user.username",
|
||||
"显示名称字段": "Display Name Field",
|
||||
"支持 JSONPath,如 name, display_name, data.user.name": "Supports JSONPath, e.g. name, display_name, data.user.name",
|
||||
"邮箱字段": "Email Field",
|
||||
"支持 JSONPath,如 email, data.user.email": "Supports JSONPath, e.g. email, data.user.email",
|
||||
"授权范围 (Scopes)": "Scopes",
|
||||
"认证方式": "Auth Style",
|
||||
"自动检测": "Auto-detect",
|
||||
"参数传递": "In Parameters",
|
||||
"Basic Auth 头": "Basic Auth Header",
|
||||
"暂无自定义 OAuth 提供商": "No custom OAuth providers",
|
||||
"确定要删除该提供商吗?": "Are you sure you want to delete this provider?",
|
||||
"创建成功": "Created successfully",
|
||||
"更新成功": "Updated successfully",
|
||||
"确认解绑": "Confirm Unbind",
|
||||
"确定要解绑 {{name}} 吗?": "Are you sure you want to unbind {{name}}?",
|
||||
"解绑成功": "Unbind successful",
|
||||
"{{name}} ID": "{{name}} ID",
|
||||
"使用 {{name}} 继续": "Continue with {{name}}",
|
||||
"端点 URL 必须以 http:// 或 https:// 开头:": "Endpoint URL must start with http:// or https://: ",
|
||||
"OAuth 配置错误:授权端点必须是完整的 URL(以 http:// 或 https:// 开头)": "OAuth configuration error: Authorization endpoint must be a full URL (starting with http:// or https://)",
|
||||
"OAuth 登录失败:": "OAuth login failed: ",
|
||||
"必填:请输入服务器地址以自动生成完整端点 URL": "Required: Enter server address to auto-generate full endpoint URLs",
|
||||
"填写服务器地址后自动生成:": "Auto-generated after entering server address: ",
|
||||
"自动生成:": "Auto-generated: ",
|
||||
"请先填写服务器地址,以自动生成完整的端点 URL": "Please enter the server address first to auto-generate full endpoint URLs",
|
||||
"端点 URL 必须是完整地址(以 http:// 或 https:// 开头)": "Endpoint URL must be a full address (starting with http:// or https://)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2740,6 +2740,49 @@
|
||||
"语言偏好": "语言偏好",
|
||||
"选择您的首选界面语言,设置将自动保存并同步到所有设备": "选择您的首选界面语言,设置将自动保存并同步到所有设备",
|
||||
"语言偏好已保存": "语言偏好已保存",
|
||||
"提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。": "提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。"
|
||||
"提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。": "提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。",
|
||||
"自定义 OAuth 提供商": "自定义 OAuth 提供商",
|
||||
"配置自定义 OAuth 提供商,支持 GitHub Enterprise、GitLab、Gitea、Nextcloud、Keycloak、ORY 等兼容 OAuth 2.0 协议的身份提供商": "配置自定义 OAuth 提供商,支持 GitHub Enterprise、GitLab、Gitea、Nextcloud、Keycloak、ORY 等兼容 OAuth 2.0 协议的身份提供商",
|
||||
"回调 URL 格式": "回调 URL 格式",
|
||||
"添加提供商": "添加提供商",
|
||||
"编辑提供商": "编辑提供商",
|
||||
"选择预设...": "选择预设...",
|
||||
"输入基础 URL": "输入基础 URL",
|
||||
"例如": "例如",
|
||||
"提供商名称": "提供商名称",
|
||||
"标识符 (Slug)": "标识符 (Slug)",
|
||||
"授权端点": "授权端点",
|
||||
"令牌端点": "令牌端点",
|
||||
"用户信息端点": "用户信息端点",
|
||||
"用户 ID 字段": "用户 ID 字段",
|
||||
"支持 JSONPath,如 sub, id, data.user.id": "支持 JSONPath,如 sub, id, data.user.id",
|
||||
"用户名字段": "用户名字段",
|
||||
"支持 JSONPath,如 preferred_username, login, data.user.username": "支持 JSONPath,如 preferred_username, login, data.user.username",
|
||||
"显示名称字段": "显示名称字段",
|
||||
"支持 JSONPath,如 name, display_name, data.user.name": "支持 JSONPath,如 name, display_name, data.user.name",
|
||||
"邮箱字段": "邮箱字段",
|
||||
"支持 JSONPath,如 email, data.user.email": "支持 JSONPath,如 email, data.user.email",
|
||||
"授权范围 (Scopes)": "授权范围 (Scopes)",
|
||||
"认证方式": "认证方式",
|
||||
"自动检测": "自动检测",
|
||||
"参数传递": "参数传递",
|
||||
"Basic Auth 头": "Basic Auth 头",
|
||||
"暂无自定义 OAuth 提供商": "暂无自定义 OAuth 提供商",
|
||||
"确定要删除该提供商吗?": "确定要删除该提供商吗?",
|
||||
"创建成功": "创建成功",
|
||||
"更新成功": "更新成功",
|
||||
"确认解绑": "确认解绑",
|
||||
"确定要解绑 {{name}} 吗?": "确定要解绑 {{name}} 吗?",
|
||||
"解绑成功": "解绑成功",
|
||||
"{{name}} ID": "{{name}} ID",
|
||||
"使用 {{name}} 继续": "使用 {{name}} 继续",
|
||||
"端点 URL 必须以 http:// 或 https:// 开头:": "端点 URL 必须以 http:// 或 https:// 开头:",
|
||||
"OAuth 配置错误:授权端点必须是完整的 URL(以 http:// 或 https:// 开头)": "OAuth 配置错误:授权端点必须是完整的 URL(以 http:// 或 https:// 开头)",
|
||||
"OAuth 登录失败:": "OAuth 登录失败:",
|
||||
"必填:请输入服务器地址以自动生成完整端点 URL": "必填:请输入服务器地址以自动生成完整端点 URL",
|
||||
"填写服务器地址后自动生成:": "填写服务器地址后自动生成:",
|
||||
"自动生成:": "自动生成:",
|
||||
"请先填写服务器地址,以自动生成完整的端点 URL": "请先填写服务器地址,以自动生成完整的端点 URL",
|
||||
"端点 URL 必须是完整地址(以 http:// 或 https:// 开头)": "端点 URL 必须是完整地址(以 http:// 或 https:// 开头)"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user