初始化导入 new-api 源码
This commit is contained in:
172
oauth/discord.go
Normal file
172
oauth/discord.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("discord", &DiscordProvider{})
|
||||
}
|
||||
|
||||
// DiscordProvider implements OAuth for Discord
|
||||
type DiscordProvider struct{}
|
||||
|
||||
type discordOAuthResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type discordUser struct {
|
||||
UID string `json:"id"`
|
||||
ID string `json:"username"`
|
||||
Name string `json:"global_name"`
|
||||
}
|
||||
|
||||
func (p *DiscordProvider) GetName() string {
|
||||
return "Discord"
|
||||
}
|
||||
|
||||
func (p *DiscordProvider) IsEnabled() bool {
|
||||
return system_setting.GetDiscordSettings().Enabled
|
||||
}
|
||||
|
||||
func (p *DiscordProvider) ExchangeToken(ctx context.Context, code string, c *gin.Context) (*OAuthToken, error) {
|
||||
if code == "" {
|
||||
return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-Discord] ExchangeToken: code=%s...", code[:min(len(code), 10)])
|
||||
|
||||
settings := system_setting.GetDiscordSettings()
|
||||
redirectUri := fmt.Sprintf("%s/oauth/discord", system_setting.ServerAddress)
|
||||
values := url.Values{}
|
||||
values.Set("client_id", settings.ClientId)
|
||||
values.Set("client_secret", settings.ClientSecret)
|
||||
values.Set("code", code)
|
||||
values.Set("grant_type", "authorization_code")
|
||||
values.Set("redirect_uri", redirectUri)
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-Discord] ExchangeToken: redirect_uri=%s", redirectUri)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "https://discord.com/api/v10/oauth2/token", 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")
|
||||
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-Discord] ExchangeToken error: %s", err.Error()))
|
||||
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthConnectFailed, map[string]any{"Provider": "Discord"}, err.Error())
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-Discord] ExchangeToken response status: %d", res.StatusCode)
|
||||
|
||||
var discordResponse discordOAuthResponse
|
||||
err = json.NewDecoder(res.Body).Decode(&discordResponse)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-Discord] ExchangeToken decode error: %s", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if discordResponse.AccessToken == "" {
|
||||
logger.LogError(ctx, "[OAuth-Discord] ExchangeToken failed: empty access token")
|
||||
return nil, NewOAuthError(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": "Discord"})
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-Discord] ExchangeToken success: scope=%s", discordResponse.Scope)
|
||||
|
||||
return &OAuthToken{
|
||||
AccessToken: discordResponse.AccessToken,
|
||||
TokenType: discordResponse.TokenType,
|
||||
RefreshToken: discordResponse.RefreshToken,
|
||||
ExpiresIn: discordResponse.ExpiresIn,
|
||||
Scope: discordResponse.Scope,
|
||||
IDToken: discordResponse.IDToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *DiscordProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*OAuthUser, error) {
|
||||
logger.LogDebug(ctx, "[OAuth-Discord] GetUserInfo: fetching user info")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://discord.com/api/v10/users/@me", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
|
||||
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-Discord] GetUserInfo error: %s", err.Error()))
|
||||
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthConnectFailed, map[string]any{"Provider": "Discord"}, err.Error())
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-Discord] GetUserInfo response status: %d", res.StatusCode)
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-Discord] GetUserInfo failed: status=%d", res.StatusCode))
|
||||
return nil, NewOAuthError(i18n.MsgOAuthGetUserErr, nil)
|
||||
}
|
||||
|
||||
var discordUser discordUser
|
||||
err = json.NewDecoder(res.Body).Decode(&discordUser)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-Discord] GetUserInfo decode error: %s", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if discordUser.UID == "" || discordUser.ID == "" {
|
||||
logger.LogError(ctx, "[OAuth-Discord] GetUserInfo failed: empty user fields")
|
||||
return nil, NewOAuthError(i18n.MsgOAuthUserInfoEmpty, map[string]any{"Provider": "Discord"})
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-Discord] GetUserInfo success: uid=%s, username=%s, name=%s", discordUser.UID, discordUser.ID, discordUser.Name)
|
||||
|
||||
return &OAuthUser{
|
||||
ProviderUserID: discordUser.UID,
|
||||
Username: discordUser.ID,
|
||||
DisplayName: discordUser.Name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *DiscordProvider) IsUserIDTaken(providerUserID string) bool {
|
||||
return model.IsDiscordIdAlreadyTaken(providerUserID)
|
||||
}
|
||||
|
||||
func (p *DiscordProvider) FillUserByProviderID(user *model.User, providerUserID string) error {
|
||||
user.DiscordId = providerUserID
|
||||
return user.FillUserByDiscordId()
|
||||
}
|
||||
|
||||
func (p *DiscordProvider) SetProviderUserID(user *model.User, providerUserID string) {
|
||||
user.DiscordId = providerUserID
|
||||
}
|
||||
|
||||
func (p *DiscordProvider) GetProviderPrefix() string {
|
||||
return "discord_"
|
||||
}
|
||||
673
oauth/generic.go
Normal file
673
oauth/generic.go
Normal file
@@ -0,0 +1,673 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
stdjson "encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"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/samber/lo"
|
||||
"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
|
||||
}
|
||||
|
||||
type accessPolicy struct {
|
||||
Logic string `json:"logic"`
|
||||
Conditions []accessCondition `json:"conditions"`
|
||||
Groups []accessPolicy `json:"groups"`
|
||||
}
|
||||
|
||||
type accessCondition struct {
|
||||
Field string `json:"field"`
|
||||
Op string `json:"op"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
type accessPolicyFailure struct {
|
||||
Field string
|
||||
Op string
|
||||
Expected any
|
||||
Current any
|
||||
}
|
||||
|
||||
var supportedAccessPolicyOps = []string{
|
||||
"eq",
|
||||
"ne",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"in",
|
||||
"not_in",
|
||||
"contains",
|
||||
"not_contains",
|
||||
"exists",
|
||||
"not_exists",
|
||||
}
|
||||
|
||||
// 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 := common.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 := normalizeAuthorizationTokenType(token.TokenType)
|
||||
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)
|
||||
|
||||
policyRaw := strings.TrimSpace(p.config.AccessPolicy)
|
||||
if policyRaw != "" {
|
||||
policy, err := parseAccessPolicy(policyRaw)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] invalid access policy: %s", p.config.Slug, err.Error()))
|
||||
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthGetUserErr, nil, "invalid access policy configuration")
|
||||
}
|
||||
allowed, failure := evaluateAccessPolicy(bodyStr, policy)
|
||||
if !allowed {
|
||||
message := renderAccessDeniedMessage(p.config.AccessDeniedMessage, p.config.Name, bodyStr, failure)
|
||||
logger.LogWarn(ctx, fmt.Sprintf("[OAuth-Generic-%s] access denied by policy: field=%s op=%s expected=%v current=%v",
|
||||
p.config.Slug, failure.Field, failure.Op, failure.Expected, failure.Current))
|
||||
return nil, &AccessDeniedError{Message: message}
|
||||
}
|
||||
}
|
||||
|
||||
return &OAuthUser{
|
||||
ProviderUserID: userId,
|
||||
Username: username,
|
||||
DisplayName: displayName,
|
||||
Email: email,
|
||||
Extra: map[string]any{
|
||||
"provider": p.config.Slug,
|
||||
},
|
||||
}, 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
|
||||
}
|
||||
|
||||
func normalizeAuthorizationTokenType(tokenType string) string {
|
||||
tokenType = strings.TrimSpace(tokenType)
|
||||
if tokenType == "" || strings.EqualFold(tokenType, "Bearer") {
|
||||
return "Bearer"
|
||||
}
|
||||
return tokenType
|
||||
}
|
||||
|
||||
// IsGenericProvider returns true for generic providers
|
||||
func (p *GenericOAuthProvider) IsGenericProvider() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func parseAccessPolicy(raw string) (*accessPolicy, error) {
|
||||
var policy accessPolicy
|
||||
if err := common.UnmarshalJsonStr(raw, &policy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAccessPolicy(&policy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
func validateAccessPolicy(policy *accessPolicy) error {
|
||||
if policy == nil {
|
||||
return errors.New("policy is nil")
|
||||
}
|
||||
|
||||
logic := strings.ToLower(strings.TrimSpace(policy.Logic))
|
||||
if logic == "" {
|
||||
logic = "and"
|
||||
}
|
||||
if !lo.Contains([]string{"and", "or"}, logic) {
|
||||
return fmt.Errorf("unsupported policy logic: %s", logic)
|
||||
}
|
||||
policy.Logic = logic
|
||||
|
||||
if len(policy.Conditions) == 0 && len(policy.Groups) == 0 {
|
||||
return errors.New("policy requires at least one condition or group")
|
||||
}
|
||||
|
||||
for index := range policy.Conditions {
|
||||
if err := validateAccessCondition(&policy.Conditions[index], index); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for index := range policy.Groups {
|
||||
if err := validateAccessPolicy(&policy.Groups[index]); err != nil {
|
||||
return fmt.Errorf("invalid policy group[%d]: %w", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAccessCondition(condition *accessCondition, index int) error {
|
||||
if condition == nil {
|
||||
return fmt.Errorf("condition[%d] is nil", index)
|
||||
}
|
||||
|
||||
condition.Field = strings.TrimSpace(condition.Field)
|
||||
if condition.Field == "" {
|
||||
return fmt.Errorf("condition[%d].field is required", index)
|
||||
}
|
||||
|
||||
condition.Op = normalizePolicyOp(condition.Op)
|
||||
if !lo.Contains(supportedAccessPolicyOps, condition.Op) {
|
||||
return fmt.Errorf("condition[%d].op is unsupported: %s", index, condition.Op)
|
||||
}
|
||||
|
||||
if lo.Contains([]string{"in", "not_in"}, condition.Op) {
|
||||
if _, ok := condition.Value.([]any); !ok {
|
||||
return fmt.Errorf("condition[%d].value must be an array for op %s", index, condition.Op)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func evaluateAccessPolicy(body string, policy *accessPolicy) (bool, *accessPolicyFailure) {
|
||||
if policy == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
logic := strings.ToLower(strings.TrimSpace(policy.Logic))
|
||||
if logic == "" {
|
||||
logic = "and"
|
||||
}
|
||||
|
||||
hasAny := len(policy.Conditions) > 0 || len(policy.Groups) > 0
|
||||
if !hasAny {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if logic == "or" {
|
||||
var firstFailure *accessPolicyFailure
|
||||
for _, cond := range policy.Conditions {
|
||||
ok, failure := evaluateAccessCondition(body, cond)
|
||||
if ok {
|
||||
return true, nil
|
||||
}
|
||||
if firstFailure == nil {
|
||||
firstFailure = failure
|
||||
}
|
||||
}
|
||||
for _, group := range policy.Groups {
|
||||
ok, failure := evaluateAccessPolicy(body, &group)
|
||||
if ok {
|
||||
return true, nil
|
||||
}
|
||||
if firstFailure == nil {
|
||||
firstFailure = failure
|
||||
}
|
||||
}
|
||||
return false, firstFailure
|
||||
}
|
||||
|
||||
for _, cond := range policy.Conditions {
|
||||
ok, failure := evaluateAccessCondition(body, cond)
|
||||
if !ok {
|
||||
return false, failure
|
||||
}
|
||||
}
|
||||
for _, group := range policy.Groups {
|
||||
ok, failure := evaluateAccessPolicy(body, &group)
|
||||
if !ok {
|
||||
return false, failure
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func evaluateAccessCondition(body string, cond accessCondition) (bool, *accessPolicyFailure) {
|
||||
path := cond.Field
|
||||
op := cond.Op
|
||||
result := gjson.Get(body, path)
|
||||
current := gjsonResultToValue(result)
|
||||
failure := &accessPolicyFailure{
|
||||
Field: path,
|
||||
Op: op,
|
||||
Expected: cond.Value,
|
||||
Current: current,
|
||||
}
|
||||
|
||||
switch op {
|
||||
case "exists":
|
||||
return result.Exists(), failure
|
||||
case "not_exists":
|
||||
return !result.Exists(), failure
|
||||
case "eq":
|
||||
return compareAny(current, cond.Value) == 0, failure
|
||||
case "ne":
|
||||
return compareAny(current, cond.Value) != 0, failure
|
||||
case "gt":
|
||||
return compareAny(current, cond.Value) > 0, failure
|
||||
case "gte":
|
||||
return compareAny(current, cond.Value) >= 0, failure
|
||||
case "lt":
|
||||
return compareAny(current, cond.Value) < 0, failure
|
||||
case "lte":
|
||||
return compareAny(current, cond.Value) <= 0, failure
|
||||
case "in":
|
||||
return valueInSlice(current, cond.Value), failure
|
||||
case "not_in":
|
||||
return !valueInSlice(current, cond.Value), failure
|
||||
case "contains":
|
||||
return containsValue(current, cond.Value), failure
|
||||
case "not_contains":
|
||||
return !containsValue(current, cond.Value), failure
|
||||
default:
|
||||
return false, failure
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePolicyOp(op string) string {
|
||||
return strings.ToLower(strings.TrimSpace(op))
|
||||
}
|
||||
|
||||
func gjsonResultToValue(result gjson.Result) any {
|
||||
if !result.Exists() {
|
||||
return nil
|
||||
}
|
||||
if result.IsArray() {
|
||||
arr := result.Array()
|
||||
values := make([]any, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
values = append(values, gjsonResultToValue(item))
|
||||
}
|
||||
return values
|
||||
}
|
||||
switch result.Type {
|
||||
case gjson.Null:
|
||||
return nil
|
||||
case gjson.True:
|
||||
return true
|
||||
case gjson.False:
|
||||
return false
|
||||
case gjson.Number:
|
||||
return result.Num
|
||||
case gjson.String:
|
||||
return result.String()
|
||||
case gjson.JSON:
|
||||
var data any
|
||||
if err := common.UnmarshalJsonStr(result.Raw, &data); err == nil {
|
||||
return data
|
||||
}
|
||||
return result.Raw
|
||||
default:
|
||||
return result.Value()
|
||||
}
|
||||
}
|
||||
|
||||
func compareAny(left any, right any) int {
|
||||
if lf, ok := toFloat(left); ok {
|
||||
if rf, ok2 := toFloat(right); ok2 {
|
||||
switch {
|
||||
case lf < rf:
|
||||
return -1
|
||||
case lf > rf:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ls := strings.TrimSpace(fmt.Sprint(left))
|
||||
rs := strings.TrimSpace(fmt.Sprint(right))
|
||||
switch {
|
||||
case ls < rs:
|
||||
return -1
|
||||
case ls > rs:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func toFloat(v any) (float64, bool) {
|
||||
switch value := v.(type) {
|
||||
case float64:
|
||||
return value, true
|
||||
case float32:
|
||||
return float64(value), true
|
||||
case int:
|
||||
return float64(value), true
|
||||
case int8:
|
||||
return float64(value), true
|
||||
case int16:
|
||||
return float64(value), true
|
||||
case int32:
|
||||
return float64(value), true
|
||||
case int64:
|
||||
return float64(value), true
|
||||
case uint:
|
||||
return float64(value), true
|
||||
case uint8:
|
||||
return float64(value), true
|
||||
case uint16:
|
||||
return float64(value), true
|
||||
case uint32:
|
||||
return float64(value), true
|
||||
case uint64:
|
||||
return float64(value), true
|
||||
case stdjson.Number:
|
||||
n, err := value.Float64()
|
||||
if err == nil {
|
||||
return n, true
|
||||
}
|
||||
case string:
|
||||
n, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err == nil {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func valueInSlice(current any, expected any) bool {
|
||||
list, ok := expected.([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return lo.ContainsBy(list, func(item any) bool {
|
||||
return compareAny(current, item) == 0
|
||||
})
|
||||
}
|
||||
|
||||
func containsValue(current any, expected any) bool {
|
||||
switch value := current.(type) {
|
||||
case string:
|
||||
target := strings.TrimSpace(fmt.Sprint(expected))
|
||||
return strings.Contains(value, target)
|
||||
case []any:
|
||||
return lo.ContainsBy(value, func(item any) bool {
|
||||
return compareAny(item, expected) == 0
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func renderAccessDeniedMessage(template string, providerName string, body string, failure *accessPolicyFailure) string {
|
||||
defaultMessage := "Access denied: your account does not meet this provider's access requirements."
|
||||
message := strings.TrimSpace(template)
|
||||
if message == "" {
|
||||
return defaultMessage
|
||||
}
|
||||
|
||||
if failure == nil {
|
||||
failure = &accessPolicyFailure{}
|
||||
}
|
||||
|
||||
replacements := map[string]string{
|
||||
"{{provider}}": providerName,
|
||||
"{{field}}": failure.Field,
|
||||
"{{op}}": failure.Op,
|
||||
"{{required}}": fmt.Sprint(failure.Expected),
|
||||
"{{current}}": fmt.Sprint(failure.Current),
|
||||
}
|
||||
|
||||
for key, value := range replacements {
|
||||
message = strings.ReplaceAll(message, key, value)
|
||||
}
|
||||
|
||||
currentPattern := regexp.MustCompile(`\{\{current\.([^}]+)\}\}`)
|
||||
message = currentPattern.ReplaceAllStringFunc(message, func(token string) string {
|
||||
match := currentPattern.FindStringSubmatch(token)
|
||||
if len(match) != 2 {
|
||||
return ""
|
||||
}
|
||||
path := strings.TrimSpace(match[1])
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(gjson.Get(body, path).String())
|
||||
})
|
||||
|
||||
requiredPattern := regexp.MustCompile(`\{\{required\.([^}]+)\}\}`)
|
||||
message = requiredPattern.ReplaceAllStringFunc(message, func(token string) string {
|
||||
match := requiredPattern.FindStringSubmatch(token)
|
||||
if len(match) != 2 {
|
||||
return ""
|
||||
}
|
||||
path := strings.TrimSpace(match[1])
|
||||
if failure.Field == path {
|
||||
return fmt.Sprint(failure.Expected)
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
return strings.TrimSpace(message)
|
||||
}
|
||||
178
oauth/github.go
Normal file
178
oauth/github.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("github", &GitHubProvider{})
|
||||
}
|
||||
|
||||
// GitHubProvider implements OAuth for GitHub
|
||||
type GitHubProvider struct{}
|
||||
|
||||
type gitHubOAuthResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Scope string `json:"scope"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
type gitHubUser struct {
|
||||
Id int64 `json:"id"` // GitHub numeric ID (permanent, never changes)
|
||||
Login string `json:"login"` // GitHub username (can be changed by user)
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func (p *GitHubProvider) GetName() string {
|
||||
return "GitHub"
|
||||
}
|
||||
|
||||
func (p *GitHubProvider) IsEnabled() bool {
|
||||
return common.GitHubOAuthEnabled
|
||||
}
|
||||
|
||||
func (p *GitHubProvider) ExchangeToken(ctx context.Context, code string, c *gin.Context) (*OAuthToken, error) {
|
||||
if code == "" {
|
||||
return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-GitHub] ExchangeToken: code=%s...", code[:min(len(code), 10)])
|
||||
|
||||
values := map[string]string{
|
||||
"client_id": common.GitHubClientId,
|
||||
"client_secret": common.GitHubClientSecret,
|
||||
"code": code,
|
||||
}
|
||||
jsonData, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "https://github.com/login/oauth/access_token", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
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-GitHub] ExchangeToken error: %s", err.Error()))
|
||||
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthConnectFailed, map[string]any{"Provider": "GitHub"}, err.Error())
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-GitHub] ExchangeToken response status: %d", res.StatusCode)
|
||||
|
||||
var oAuthResponse gitHubOAuthResponse
|
||||
err = json.NewDecoder(res.Body).Decode(&oAuthResponse)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] ExchangeToken decode error: %s", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if oAuthResponse.AccessToken == "" {
|
||||
logger.LogError(ctx, "[OAuth-GitHub] ExchangeToken failed: empty access token")
|
||||
return nil, NewOAuthError(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": "GitHub"})
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-GitHub] ExchangeToken success: scope=%s", oAuthResponse.Scope)
|
||||
|
||||
return &OAuthToken{
|
||||
AccessToken: oAuthResponse.AccessToken,
|
||||
TokenType: oAuthResponse.TokenType,
|
||||
Scope: oAuthResponse.Scope,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *GitHubProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*OAuthUser, error) {
|
||||
logger.LogDebug(ctx, "[OAuth-GitHub] GetUserInfo: fetching user info")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
|
||||
|
||||
client := http.Client{
|
||||
Timeout: 20 * time.Second,
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] GetUserInfo error: %s", err.Error()))
|
||||
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthConnectFailed, map[string]any{"Provider": "GitHub"}, err.Error())
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-GitHub] GetUserInfo response status: %d", res.StatusCode)
|
||||
|
||||
// Check for non-200 status codes before attempting to decode
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 500 {
|
||||
bodyStr = bodyStr[:500] + "..."
|
||||
}
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] GetUserInfo failed: status=%d, body=%s", res.StatusCode, bodyStr))
|
||||
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthGetUserErr, map[string]any{"Provider": "GitHub"}, fmt.Sprintf("status %d", res.StatusCode))
|
||||
}
|
||||
|
||||
var githubUser gitHubUser
|
||||
err = json.NewDecoder(res.Body).Decode(&githubUser)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] GetUserInfo decode error: %s", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if githubUser.Id == 0 || githubUser.Login == "" {
|
||||
logger.LogError(ctx, "[OAuth-GitHub] GetUserInfo failed: empty id or login field")
|
||||
return nil, NewOAuthError(i18n.MsgOAuthUserInfoEmpty, map[string]any{"Provider": "GitHub"})
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-GitHub] GetUserInfo success: id=%d, login=%s, name=%s, email=%s",
|
||||
githubUser.Id, githubUser.Login, githubUser.Name, githubUser.Email)
|
||||
|
||||
return &OAuthUser{
|
||||
ProviderUserID: strconv.FormatInt(githubUser.Id, 10), // Use numeric ID as primary identifier
|
||||
Username: githubUser.Login,
|
||||
DisplayName: githubUser.Name,
|
||||
Email: githubUser.Email,
|
||||
Extra: map[string]any{
|
||||
"legacy_id": githubUser.Login, // Store login for migration from old accounts
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *GitHubProvider) IsUserIDTaken(providerUserID string) bool {
|
||||
return model.IsGitHubIdAlreadyTaken(providerUserID)
|
||||
}
|
||||
|
||||
func (p *GitHubProvider) FillUserByProviderID(user *model.User, providerUserID string) error {
|
||||
user.GitHubId = providerUserID
|
||||
return user.FillUserByGitHubId()
|
||||
}
|
||||
|
||||
func (p *GitHubProvider) SetProviderUserID(user *model.User, providerUserID string) {
|
||||
user.GitHubId = providerUserID
|
||||
}
|
||||
|
||||
func (p *GitHubProvider) GetProviderPrefix() string {
|
||||
return "github_"
|
||||
}
|
||||
195
oauth/linuxdo.go
Normal file
195
oauth/linuxdo.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("linuxdo", &LinuxDOProvider{})
|
||||
}
|
||||
|
||||
// LinuxDOProvider implements OAuth for Linux DO
|
||||
type LinuxDOProvider struct{}
|
||||
|
||||
type linuxdoUser struct {
|
||||
Id int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
TrustLevel int `json:"trust_level"`
|
||||
Silenced bool `json:"silenced"`
|
||||
}
|
||||
|
||||
func (p *LinuxDOProvider) GetName() string {
|
||||
return "Linux DO"
|
||||
}
|
||||
|
||||
func (p *LinuxDOProvider) IsEnabled() bool {
|
||||
return common.LinuxDOOAuthEnabled
|
||||
}
|
||||
|
||||
func (p *LinuxDOProvider) ExchangeToken(ctx context.Context, code string, c *gin.Context) (*OAuthToken, error) {
|
||||
if code == "" {
|
||||
return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-LinuxDO] ExchangeToken: code=%s...", code[:min(len(code), 10)])
|
||||
|
||||
// Get access token using Basic auth
|
||||
tokenEndpoint := common.GetEnvOrDefaultString("LINUX_DO_TOKEN_ENDPOINT", "https://connect.linux.do/oauth2/token")
|
||||
credentials := common.LinuxDOClientId + ":" + common.LinuxDOClientSecret
|
||||
basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
|
||||
|
||||
// Get redirect URI from request
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
redirectURI := fmt.Sprintf("%s://%s/api/oauth/linuxdo", scheme, c.Request.Host)
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-LinuxDO] ExchangeToken: token_endpoint=%s, redirect_uri=%s", tokenEndpoint, redirectURI)
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "authorization_code")
|
||||
data.Set("code", code)
|
||||
data.Set("redirect_uri", redirectURI)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", tokenEndpoint, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", basicAuth)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := http.Client{Timeout: 5 * time.Second}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-LinuxDO] ExchangeToken error: %s", err.Error()))
|
||||
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthConnectFailed, map[string]any{"Provider": "Linux DO"}, err.Error())
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-LinuxDO] ExchangeToken response status: %d", res.StatusCode)
|
||||
|
||||
var tokenRes struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&tokenRes); err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-LinuxDO] ExchangeToken decode error: %s", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if tokenRes.AccessToken == "" {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-LinuxDO] ExchangeToken failed: %s", tokenRes.Message))
|
||||
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": "Linux DO"}, tokenRes.Message)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-LinuxDO] ExchangeToken success")
|
||||
|
||||
return &OAuthToken{
|
||||
AccessToken: tokenRes.AccessToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *LinuxDOProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*OAuthUser, error) {
|
||||
userEndpoint := common.GetEnvOrDefaultString("LINUX_DO_USER_ENDPOINT", "https://connect.linux.do/api/user")
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-LinuxDO] GetUserInfo: user_endpoint=%s", userEndpoint)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", userEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := http.Client{Timeout: 5 * time.Second}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-LinuxDO] GetUserInfo error: %s", err.Error()))
|
||||
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthConnectFailed, map[string]any{"Provider": "Linux DO"}, err.Error())
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-LinuxDO] GetUserInfo response status: %d", res.StatusCode)
|
||||
|
||||
var linuxdoUser linuxdoUser
|
||||
if err := json.NewDecoder(res.Body).Decode(&linuxdoUser); err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-LinuxDO] GetUserInfo decode error: %s", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if linuxdoUser.Id == 0 {
|
||||
logger.LogError(ctx, "[OAuth-LinuxDO] GetUserInfo failed: invalid user id")
|
||||
return nil, NewOAuthError(i18n.MsgOAuthUserInfoEmpty, map[string]any{"Provider": "Linux DO"})
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-LinuxDO] GetUserInfo: id=%d, username=%s, name=%s, trust_level=%d, active=%v, silenced=%v",
|
||||
linuxdoUser.Id, linuxdoUser.Username, linuxdoUser.Name, linuxdoUser.TrustLevel, linuxdoUser.Active, linuxdoUser.Silenced)
|
||||
|
||||
// Check trust level
|
||||
if linuxdoUser.TrustLevel < common.LinuxDOMinimumTrustLevel {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("[OAuth-LinuxDO] GetUserInfo: trust level too low (required=%d, current=%d)",
|
||||
common.LinuxDOMinimumTrustLevel, linuxdoUser.TrustLevel))
|
||||
return nil, &TrustLevelError{
|
||||
Required: common.LinuxDOMinimumTrustLevel,
|
||||
Current: linuxdoUser.TrustLevel,
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-LinuxDO] GetUserInfo success: id=%d, username=%s", linuxdoUser.Id, linuxdoUser.Username)
|
||||
|
||||
return &OAuthUser{
|
||||
ProviderUserID: strconv.Itoa(linuxdoUser.Id),
|
||||
Username: linuxdoUser.Username,
|
||||
DisplayName: linuxdoUser.Name,
|
||||
Extra: map[string]any{
|
||||
"trust_level": linuxdoUser.TrustLevel,
|
||||
"active": linuxdoUser.Active,
|
||||
"silenced": linuxdoUser.Silenced,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *LinuxDOProvider) IsUserIDTaken(providerUserID string) bool {
|
||||
return model.IsLinuxDOIdAlreadyTaken(providerUserID)
|
||||
}
|
||||
|
||||
func (p *LinuxDOProvider) FillUserByProviderID(user *model.User, providerUserID string) error {
|
||||
user.LinuxDOId = providerUserID
|
||||
return user.FillUserByLinuxDOId()
|
||||
}
|
||||
|
||||
func (p *LinuxDOProvider) SetProviderUserID(user *model.User, providerUserID string) {
|
||||
user.LinuxDOId = providerUserID
|
||||
}
|
||||
|
||||
func (p *LinuxDOProvider) GetProviderPrefix() string {
|
||||
return "linuxdo_"
|
||||
}
|
||||
|
||||
// TrustLevelError indicates the user's trust level is too low
|
||||
type TrustLevelError struct {
|
||||
Required int
|
||||
Current int
|
||||
}
|
||||
|
||||
func (e *TrustLevelError) Error() string {
|
||||
return "trust level too low"
|
||||
}
|
||||
177
oauth/oidc.go
Normal file
177
oauth/oidc.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("oidc", &OIDCProvider{})
|
||||
}
|
||||
|
||||
// OIDCProvider implements OAuth for OIDC
|
||||
type OIDCProvider struct{}
|
||||
|
||||
type oidcOAuthResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type oidcUser struct {
|
||||
OpenID string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) GetName() string {
|
||||
return "OIDC"
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) IsEnabled() bool {
|
||||
return system_setting.GetOIDCSettings().Enabled
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) ExchangeToken(ctx context.Context, code string, c *gin.Context) (*OAuthToken, error) {
|
||||
if code == "" {
|
||||
return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-OIDC] ExchangeToken: code=%s...", code[:min(len(code), 10)])
|
||||
|
||||
settings := system_setting.GetOIDCSettings()
|
||||
redirectUri := fmt.Sprintf("%s/oauth/oidc", system_setting.ServerAddress)
|
||||
values := url.Values{}
|
||||
values.Set("client_id", settings.ClientId)
|
||||
values.Set("client_secret", settings.ClientSecret)
|
||||
values.Set("code", code)
|
||||
values.Set("grant_type", "authorization_code")
|
||||
values.Set("redirect_uri", redirectUri)
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-OIDC] ExchangeToken: token_endpoint=%s, redirect_uri=%s", settings.TokenEndpoint, redirectUri)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", settings.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")
|
||||
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] ExchangeToken error: %s", err.Error()))
|
||||
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthConnectFailed, map[string]any{"Provider": "OIDC"}, err.Error())
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-OIDC] ExchangeToken response status: %d", res.StatusCode)
|
||||
|
||||
var oidcResponse oidcOAuthResponse
|
||||
err = json.NewDecoder(res.Body).Decode(&oidcResponse)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] ExchangeToken decode error: %s", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if oidcResponse.AccessToken == "" {
|
||||
logger.LogError(ctx, "[OAuth-OIDC] ExchangeToken failed: empty access token")
|
||||
return nil, NewOAuthError(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": "OIDC"})
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-OIDC] ExchangeToken success: scope=%s", oidcResponse.Scope)
|
||||
|
||||
return &OAuthToken{
|
||||
AccessToken: oidcResponse.AccessToken,
|
||||
TokenType: oidcResponse.TokenType,
|
||||
RefreshToken: oidcResponse.RefreshToken,
|
||||
ExpiresIn: oidcResponse.ExpiresIn,
|
||||
Scope: oidcResponse.Scope,
|
||||
IDToken: oidcResponse.IDToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*OAuthUser, error) {
|
||||
settings := system_setting.GetOIDCSettings()
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-OIDC] GetUserInfo: userinfo_endpoint=%s", settings.UserInfoEndpoint)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", settings.UserInfoEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
|
||||
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] GetUserInfo error: %s", err.Error()))
|
||||
return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthConnectFailed, map[string]any{"Provider": "OIDC"}, err.Error())
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-OIDC] GetUserInfo response status: %d", res.StatusCode)
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] GetUserInfo failed: status=%d", res.StatusCode))
|
||||
return nil, NewOAuthError(i18n.MsgOAuthGetUserErr, nil)
|
||||
}
|
||||
|
||||
var oidcUser oidcUser
|
||||
err = json.NewDecoder(res.Body).Decode(&oidcUser)
|
||||
if err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] GetUserInfo decode error: %s", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if oidcUser.OpenID == "" || oidcUser.Email == "" {
|
||||
logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] GetUserInfo failed: empty fields (sub=%s, email=%s)", oidcUser.OpenID, oidcUser.Email))
|
||||
return nil, NewOAuthError(i18n.MsgOAuthUserInfoEmpty, map[string]any{"Provider": "OIDC"})
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, "[OAuth-OIDC] GetUserInfo success: sub=%s, username=%s, name=%s, email=%s", oidcUser.OpenID, oidcUser.PreferredUsername, oidcUser.Name, oidcUser.Email)
|
||||
|
||||
return &OAuthUser{
|
||||
ProviderUserID: oidcUser.OpenID,
|
||||
Username: oidcUser.PreferredUsername,
|
||||
DisplayName: oidcUser.Name,
|
||||
Email: oidcUser.Email,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) IsUserIDTaken(providerUserID string) bool {
|
||||
return model.IsOidcIdAlreadyTaken(providerUserID)
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) FillUserByProviderID(user *model.User, providerUserID string) error {
|
||||
user.OidcId = providerUserID
|
||||
return user.FillUserByOidcId()
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) SetProviderUserID(user *model.User, providerUserID string) {
|
||||
user.OidcId = providerUserID
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) GetProviderPrefix() string {
|
||||
return "oidc_"
|
||||
}
|
||||
36
oauth/provider.go
Normal file
36
oauth/provider.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Provider defines the interface for OAuth providers
|
||||
type Provider interface {
|
||||
// GetName returns the display name of the provider (e.g., "GitHub", "Discord")
|
||||
GetName() string
|
||||
|
||||
// IsEnabled returns whether this OAuth provider is enabled
|
||||
IsEnabled() bool
|
||||
|
||||
// ExchangeToken exchanges the authorization code for an access token
|
||||
// The gin.Context is passed for providers that need request info (e.g., for redirect_uri)
|
||||
ExchangeToken(ctx context.Context, code string, c *gin.Context) (*OAuthToken, error)
|
||||
|
||||
// GetUserInfo retrieves user information using the access token
|
||||
GetUserInfo(ctx context.Context, token *OAuthToken) (*OAuthUser, error)
|
||||
|
||||
// IsUserIDTaken checks if the provider user ID is already associated with an account
|
||||
IsUserIDTaken(providerUserID string) bool
|
||||
|
||||
// FillUserByProviderID fills the user model by provider user ID
|
||||
FillUserByProviderID(user *model.User, providerUserID string) error
|
||||
|
||||
// SetProviderUserID sets the provider user ID on the user model
|
||||
SetProviderUserID(user *model.User, providerUserID string)
|
||||
|
||||
// GetProviderPrefix returns the prefix for auto-generated usernames (e.g., "github_")
|
||||
GetProviderPrefix() string
|
||||
}
|
||||
134
oauth/registry.go
Normal file
134
oauth/registry.go
Normal file
@@ -0,0 +1,134 @@
|
||||
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
|
||||
func Register(name string, provider Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
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()
|
||||
defer mu.RUnlock()
|
||||
return providers[name]
|
||||
}
|
||||
|
||||
// GetAllProviders returns all registered OAuth providers
|
||||
func GetAllProviders() map[string]Provider {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
result := make(map[string]Provider, len(providers))
|
||||
for k, v := range providers {
|
||||
result[k] = v
|
||||
}
|
||||
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()
|
||||
defer mu.RUnlock()
|
||||
_, 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)
|
||||
}
|
||||
68
oauth/types.go
Normal file
68
oauth/types.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package oauth
|
||||
|
||||
// OAuthToken represents the token received from OAuth provider
|
||||
type OAuthToken struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
}
|
||||
|
||||
// OAuthUser represents the user info from OAuth provider
|
||||
type OAuthUser struct {
|
||||
// ProviderUserID is the unique identifier from the OAuth provider
|
||||
ProviderUserID string
|
||||
// Username is the username from the OAuth provider (e.g., GitHub login)
|
||||
Username string
|
||||
// DisplayName is the display name from the OAuth provider
|
||||
DisplayName string
|
||||
// Email is the email from the OAuth provider
|
||||
Email string
|
||||
// Extra contains any additional provider-specific data
|
||||
Extra map[string]any
|
||||
}
|
||||
|
||||
// OAuthError represents a translatable OAuth error
|
||||
type OAuthError struct {
|
||||
// MsgKey is the i18n message key
|
||||
MsgKey string
|
||||
// Params contains optional parameters for the message template
|
||||
Params map[string]any
|
||||
// RawError is the underlying error for logging purposes
|
||||
RawError string
|
||||
}
|
||||
|
||||
func (e *OAuthError) Error() string {
|
||||
if e.RawError != "" {
|
||||
return e.RawError
|
||||
}
|
||||
return e.MsgKey
|
||||
}
|
||||
|
||||
// NewOAuthError creates a new OAuth error with the given message key
|
||||
func NewOAuthError(msgKey string, params map[string]any) *OAuthError {
|
||||
return &OAuthError{
|
||||
MsgKey: msgKey,
|
||||
Params: params,
|
||||
}
|
||||
}
|
||||
|
||||
// NewOAuthErrorWithRaw creates a new OAuth error with raw error message for logging
|
||||
func NewOAuthErrorWithRaw(msgKey string, params map[string]any, rawError string) *OAuthError {
|
||||
return &OAuthError{
|
||||
MsgKey: msgKey,
|
||||
Params: params,
|
||||
RawError: rawError,
|
||||
}
|
||||
}
|
||||
|
||||
// AccessDeniedError is a direct user-facing access denial message.
|
||||
type AccessDeniedError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *AccessDeniedError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
Reference in New Issue
Block a user