Files

603 lines
18 KiB
Go

package controller
import (
"context"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"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"`
Icon string `json:"icon"`
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"`
AccessPolicy string `json:"access_policy"`
AccessDeniedMessage string `json:"access_denied_message"`
}
type UserOAuthBindingResponse struct {
ProviderId int `json:"provider_id"`
ProviderName string `json:"provider_name"`
ProviderSlug string `json:"provider_slug"`
ProviderIcon string `json:"provider_icon"`
ProviderUserId string `json:"provider_user_id"`
}
func toCustomOAuthProviderResponse(p *model.CustomOAuthProvider) *CustomOAuthProviderResponse {
return &CustomOAuthProviderResponse{
Id: p.Id,
Name: p.Name,
Slug: p.Slug,
Icon: p.Icon,
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,
AccessPolicy: p.AccessPolicy,
AccessDeniedMessage: p.AccessDeniedMessage,
}
}
// 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.ApiErrorT(c, "Invalid ID")
return
}
provider, err := model.GetCustomOAuthProviderById(id)
if err != nil {
common.ApiErrorT(c, "OAuth provider not found")
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"`
Icon string `json:"icon"`
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"`
AccessPolicy string `json:"access_policy"`
AccessDeniedMessage string `json:"access_denied_message"`
}
type FetchCustomOAuthDiscoveryRequest struct {
WellKnownURL string `json:"well_known_url"`
IssuerURL string `json:"issuer_url"`
}
// FetchCustomOAuthDiscovery fetches OIDC discovery document via backend (root-only route)
func FetchCustomOAuthDiscovery(c *gin.Context) {
var req FetchCustomOAuthDiscoveryRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorT(c, "Invalid request parameters: {{error}}", map[string]any{"error": err.Error()})
return
}
wellKnownURL := strings.TrimSpace(req.WellKnownURL)
issuerURL := strings.TrimSpace(req.IssuerURL)
if wellKnownURL == "" && issuerURL == "" {
common.ApiErrorT(c, "Enter the Discovery URL or Issuer URL first")
return
}
targetURL := wellKnownURL
if targetURL == "" {
targetURL = strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration"
}
targetURL = strings.TrimSpace(targetURL)
parsedURL, err := url.Parse(targetURL)
if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
common.ApiErrorT(c, "Invalid Discovery URL. Only http and https are supported")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 20*time.Second)
defer cancel()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
common.ApiErrorT(c, "Failed to create the Discovery request: {{error}}", map[string]any{"error": err.Error()})
return
}
httpReq.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
common.ApiErrorT(c, "Failed to fetch the Discovery configuration: {{error}}", map[string]any{"error": err.Error()})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
message := strings.TrimSpace(string(body))
if message == "" {
message = resp.Status
}
common.ApiErrorT(c, "Failed to fetch the Discovery configuration: {{error}}", map[string]any{"error": message})
return
}
var discovery map[string]any
if err = common.DecodeJson(resp.Body, &discovery); err != nil {
common.ApiErrorT(c, "Failed to parse the Discovery configuration: {{error}}", map[string]any{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"well_known_url": targetURL,
"discovery": discovery,
},
})
}
// CreateCustomOAuthProvider creates a new custom OAuth provider
func CreateCustomOAuthProvider(c *gin.Context) {
var req CreateCustomOAuthProviderRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorT(c, "Invalid request parameters: {{error}}", map[string]any{"error": err.Error()})
return
}
// Check if slug is already taken
if model.IsSlugTaken(req.Slug, 0) {
common.ApiErrorT(c, "This slug is already in use")
return
}
// Check if slug conflicts with built-in providers
if oauth.IsProviderRegistered(req.Slug) && !oauth.IsCustomProvider(req.Slug) {
common.ApiErrorT(c, "This slug conflicts with a built-in OAuth provider")
return
}
provider := &model.CustomOAuthProvider{
Name: req.Name,
Slug: req.Slug,
Icon: req.Icon,
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,
AccessPolicy: req.AccessPolicy,
AccessDeniedMessage: req.AccessDeniedMessage,
}
if err := model.CreateCustomOAuthProvider(provider); err != nil {
common.ApiError(c, err)
return
}
// Register the provider in the OAuth registry
oauth.RegisterOrUpdateCustomProvider(provider)
common.ApiSuccessT(c, "Provider created successfully", toCustomOAuthProviderResponse(provider))
}
// UpdateCustomOAuthProviderRequest is the request structure for updating a custom OAuth provider
type UpdateCustomOAuthProviderRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
Icon *string `json:"icon"` // Optional: if nil, keep existing
Enabled *bool `json:"enabled"` // Optional: if nil, keep existing
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"` // Optional: if nil, keep existing
AuthStyle *int `json:"auth_style"` // Optional: if nil, keep existing
AccessPolicy *string `json:"access_policy"` // Optional: if nil, keep existing
AccessDeniedMessage *string `json:"access_denied_message"` // Optional: if nil, keep existing
}
// 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.ApiErrorT(c, "Invalid ID")
return
}
var req UpdateCustomOAuthProviderRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorT(c, "Invalid request parameters: {{error}}", map[string]any{"error": err.Error()})
return
}
// Get existing provider
provider, err := model.GetCustomOAuthProviderById(id)
if err != nil {
common.ApiErrorT(c, "OAuth provider not found")
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.ApiErrorT(c, "This slug is already in use")
return
}
// Check if slug conflicts with built-in providers
if oauth.IsProviderRegistered(req.Slug) && !oauth.IsCustomProvider(req.Slug) {
common.ApiErrorT(c, "This slug conflicts with a built-in OAuth provider")
return
}
}
// Update fields
if req.Name != "" {
provider.Name = req.Name
}
if req.Slug != "" {
provider.Slug = req.Slug
}
if req.Icon != nil {
provider.Icon = *req.Icon
}
if req.Enabled != nil {
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
}
if req.WellKnown != nil {
provider.WellKnown = *req.WellKnown
}
if req.AuthStyle != nil {
provider.AuthStyle = *req.AuthStyle
}
if req.AccessPolicy != nil {
provider.AccessPolicy = *req.AccessPolicy
}
if req.AccessDeniedMessage != nil {
provider.AccessDeniedMessage = *req.AccessDeniedMessage
}
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)
common.ApiSuccessT(c, "Provider updated successfully", 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.ApiErrorT(c, "Invalid ID")
return
}
// Get existing provider to get slug
provider, err := model.GetCustomOAuthProviderById(id)
if err != nil {
common.ApiErrorT(c, "OAuth provider not found")
return
}
// Check if there are any user bindings
count, err := model.GetBindingCountByProviderId(id)
if err != nil {
common.SysError(common.LogText("Failed to get binding count for provider %s: %s", strconv.Itoa(id), err.Error()))
common.ApiErrorT(c, "Failed to check user bindings, please try again later")
return
}
if count > 0 {
common.ApiErrorT(c, "This OAuth provider still has linked users and cannot be deleted. Unbind all users first.")
return
}
if err := model.DeleteCustomOAuthProvider(id); err != nil {
common.ApiError(c, err)
return
}
// Unregister the provider from the OAuth registry
oauth.UnregisterCustomProvider(provider.Slug)
common.ApiSuccessT(c, "Provider deleted successfully", nil)
}
func buildUserOAuthBindingsResponse(userId int) ([]UserOAuthBindingResponse, error) {
bindings, err := model.GetUserOAuthBindingsByUserId(userId)
if err != nil {
return nil, err
}
response := make([]UserOAuthBindingResponse, 0, len(bindings))
for _, binding := range bindings {
provider, err := model.GetCustomOAuthProviderById(binding.ProviderId)
if err != nil {
continue
}
response = append(response, UserOAuthBindingResponse{
ProviderId: binding.ProviderId,
ProviderName: provider.Name,
ProviderSlug: provider.Slug,
ProviderIcon: provider.Icon,
ProviderUserId: binding.ProviderUserId,
})
}
return response, nil
}
// GetUserOAuthBindings returns all OAuth bindings for the current user
func GetUserOAuthBindings(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
common.ApiErrorT(c, "Not logged in")
return
}
response, err := buildUserOAuthBindingsResponse(userId)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": response,
})
}
func GetUserOAuthBindingsByAdmin(c *gin.Context) {
userIdStr := c.Param("id")
userId, err := strconv.Atoi(userIdStr)
if err != nil {
common.ApiErrorMsg(c, "invalid user id")
return
}
targetUser, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}
myRole := c.GetInt("role")
if !canManageTargetRole(myRole, targetUser.Role) {
common.ApiErrorMsg(c, "no permission")
return
}
response, err := buildUserOAuthBindingsResponse(userId)
if err != nil {
common.ApiError(c, err)
return
}
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) {
identity, ok := middleware.GetStepUpIdentity(c)
if !ok {
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
return
}
providerIdStr := c.Param("provider_id")
providerId, err := strconv.Atoi(providerIdStr)
if err != nil || providerId <= 0 {
common.ApiErrorT(c, "Invalid provider ID")
return
}
succeeded, notificationFailed := false, false
defer func() {
recordUserSecurityAudit(c, identity.UserID, "user.binding_unbind", map[string]any{"provider_id": providerId, "success": succeeded, "notification_failed": notificationFailed})
}()
context, err := common.Marshal(service.AccountUnbindingContext{ProviderID: providerId})
if err != nil {
writeSecurityOperationError(c, err)
return
}
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccountUnbind, Context: context}) == nil {
return
}
if err := service.UnbindAccountOAuth(identity, providerId); err != nil {
writeSecurityOperationError(c, err)
return
}
succeeded = true
user, err := model.GetUserById(identity.UserID, false)
if err != nil {
writeSecurityOperationError(c, err)
return
}
notificationFailed = service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventLoginUnlinked) != nil
common.ApiSuccessT(c, "Unbound successfully", gin.H{"notification_warning": notificationFailed})
}
func UnbindCustomOAuthByAdmin(c *gin.Context) {
userIdStr := c.Param("id")
userId, err := strconv.Atoi(userIdStr)
if err != nil {
common.ApiErrorMsg(c, "invalid user id")
return
}
targetUser, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}
myRole := c.GetInt("role")
if !canManageTargetRole(myRole, targetUser.Role) {
common.ApiErrorMsg(c, "no permission")
return
}
providerIdStr := c.Param("provider_id")
providerId, err := strconv.Atoi(providerIdStr)
if err != nil || providerId <= 0 {
common.ApiErrorT(c, "Invalid provider ID")
return
}
authorization := requireAdminUserProof(c, service.VerificationScopeAdminUserBindingClear, service.AdminUserBindingContext{UserID: userId, ProviderID: providerId})
if authorization == nil {
return
}
if err := model.DeleteUserOAuthBinding(userId, providerId); err != nil {
common.ApiError(c, err)
return
}
recordManageAuditFor(c, userId, "user.binding_clear", map[string]any{
"bindingType": "custom_oauth",
"provider_id": providerId,
"username": targetUser.Username,
"verification_method": authorization.Method,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "success",
})
}