Browse Source

feat: 关联 discord 账号

StageDog 1 month ago
parent
commit
87811a0493

+ 1 - 0
README.en.md

@@ -193,6 +193,7 @@ docker run --name new-api -d --restart always \
 
 ### 🔐 Authorization and Security
 
+- 😈 Discord authorization login
 - 🤖 LinuxDO authorization login
 - 📱 Telegram authorization login
 - 🔑 OIDC unified authentication

+ 1 - 0
README.md

@@ -193,6 +193,7 @@ docker run --name new-api -d --restart always \
 
 ### 🔐 授权与安全
 
+- 😈 Discord 授权登录
 - 🤖 LinuxDO 授权登录
 - 📱 Telegram 授权登录
 - 🔑 OIDC 统一认证

+ 3 - 0
common/constants.go

@@ -44,6 +44,7 @@ var PasswordLoginEnabled = true
 var PasswordRegisterEnabled = true
 var EmailVerificationEnabled = false
 var GitHubOAuthEnabled = false
+var DiscordOAuthEnabled = false
 var LinuxDOOAuthEnabled = false
 var WeChatAuthEnabled = false
 var TelegramOAuthEnabled = false
@@ -82,6 +83,8 @@ var SMTPToken = ""
 
 var GitHubClientId = ""
 var GitHubClientSecret = ""
+var DiscordClientId = ""
+var DiscordClientSecret = ""
 var LinuxDOClientId = ""
 var LinuxDOClientSecret = ""
 var LinuxDOMinimumTrustLevel = 0

+ 224 - 0
controller/discord.go

@@ -0,0 +1,224 @@
+package controller
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/setting/system_setting"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-gonic/gin"
+)
+
+type DiscordResponse 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 getDiscordUserInfoByCode(code string) (*DiscordUser, error) {
+	if code == "" {
+		return nil, errors.New("无效的参数")
+	}
+
+	values := url.Values{}
+	values.Set("client_id", common.DiscordClientId)
+	values.Set("client_secret", common.DiscordClientSecret)
+	values.Set("code", code)
+	values.Set("grant_type", "authorization_code")
+	values.Set("redirect_uri", fmt.Sprintf("%s/oauth/discord", system_setting.ServerAddress))
+	formData := values.Encode()
+	req, err := http.NewRequest("POST", "https://discord.com/api/v10/oauth2/token", strings.NewReader(formData))
+	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 {
+		common.SysLog(err.Error())
+		return nil, errors.New("无法连接至 Discord 服务器,请稍后重试!")
+	}
+	defer res.Body.Close()
+	var discordResponse DiscordResponse
+	err = json.NewDecoder(res.Body).Decode(&discordResponse)
+	if err != nil {
+		return nil, err
+	}
+
+	if discordResponse.AccessToken == "" {
+		common.SysError("Discord 获取 Token 失败,请检查设置!")
+		return nil, errors.New("Discord 获取 Token 失败,请检查设置!")
+	}
+
+	req, err = http.NewRequest("GET", "https://discord.com/api/v10/users/@me", nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Authorization", "Bearer "+discordResponse.AccessToken)
+	res2, err := client.Do(req)
+	if err != nil {
+		common.SysLog(err.Error())
+		return nil, errors.New("无法连接至 Discord 服务器,请稍后重试!")
+	}
+	defer res2.Body.Close()
+	if res2.StatusCode != http.StatusOK {
+		common.SysError("Discord 获取用户信息失败!请检查设置!")
+		return nil, errors.New("Discord 获取用户信息失败!请检查设置!")
+	}
+
+	var discordUser DiscordUser
+	err = json.NewDecoder(res2.Body).Decode(&discordUser)
+	if err != nil {
+		return nil, err
+	}
+	if discordUser.UID == "" || discordUser.ID == "" {
+		common.SysError("Discord 获取用户信息为空!请检查设置!")
+		return nil, errors.New("Discord 获取用户信息为空!请检查设置!")
+	}
+	return &discordUser, nil
+}
+
+func DiscordOAuth(c *gin.Context) {
+	session := sessions.Default(c)
+	state := c.Query("state")
+	if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
+		c.JSON(http.StatusForbidden, gin.H{
+			"success": false,
+			"message": "state is empty or not same",
+		})
+		return
+	}
+	username := session.Get("username")
+	if username != nil {
+		DiscordBind(c)
+		return
+	}
+	if !common.DiscordOAuthEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "管理员未开启通过 discord 登录以及注册",
+		})
+		return
+	}
+	code := c.Query("code")
+	discordUser, err := getDiscordUserInfoByCode(code)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	user := model.User{
+		DiscordId: discordUser.UID,
+	}
+	if model.IsDiscordIdAlreadyTaken(user.DiscordId) {
+		err := user.FillUserByDiscordId()
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
+	} else {
+		if common.RegisterEnabled {
+			if discordUser.ID != "" {
+				user.Username = discordUser.ID
+			} else {
+				user.Username = "discord_" + strconv.Itoa(model.GetMaxUserId()+1)
+			}
+			if discordUser.Name != "" {
+				user.DisplayName = discordUser.Name
+			} else {
+				user.DisplayName = "Discord User"
+			}
+			err := user.Insert(0)
+			if err != nil {
+				c.JSON(http.StatusOK, gin.H{
+					"success": false,
+					"message": err.Error(),
+				})
+				return
+			}
+		} else {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "管理员关闭了新用户注册",
+			})
+			return
+		}
+	}
+
+	if user.Status != common.UserStatusEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"message": "用户已被封禁",
+			"success": false,
+		})
+		return
+	}
+	setupLogin(&user, c)
+}
+
+func DiscordBind(c *gin.Context) {
+	if !common.DiscordOAuthEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "管理员未开启通过 Discord 登录以及注册",
+		})
+		return
+	}
+	code := c.Query("code")
+	discordUser, err := getDiscordUserInfoByCode(code)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	user := model.User{
+		DiscordId: discordUser.UID,
+	}
+	if model.IsDiscordIdAlreadyTaken(user.DiscordId) {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "该 Discord 账户已被绑定",
+		})
+		return
+	}
+	session := sessions.Default(c)
+	id := session.Get("id")
+	// id := c.GetInt("id")  // critical bug!
+	user.Id = id.(int)
+	err = user.FillUserById()
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	user.DiscordId = discordUser.UID
+	err = user.Update(false)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "bind",
+	})
+}

+ 2 - 0
controller/misc.go

@@ -52,6 +52,8 @@ func GetStatus(c *gin.Context) {
 		"email_verification":          common.EmailVerificationEnabled,
 		"github_oauth":                common.GitHubOAuthEnabled,
 		"github_client_id":            common.GitHubClientId,
+		"discord_oauth":               common.DiscordOAuthEnabled,
+		"discord_client_id":           common.DiscordClientId,
 		"linuxdo_oauth":               common.LinuxDOOAuthEnabled,
 		"linuxdo_client_id":           common.LinuxDOClientId,
 		"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,

+ 8 - 0
controller/option.go

@@ -71,6 +71,14 @@ func UpdateOption(c *gin.Context) {
 			})
 			return
 		}
+	case "DiscordOAuthEnabled":
+		if option.Value == "true" && common.DiscordClientId == "" {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "无法启用 Discord OAuth,请先填入 Discord Client Id 以及 Discord Client Secret!",
+			})
+			return
+		}
 	case "oidc.enabled":
 		if option.Value == "true" && system_setting.GetOIDCSettings().ClientId == "" {
 			c.JSON(http.StatusOK, gin.H{

+ 1 - 0
controller/user.go

@@ -453,6 +453,7 @@ func GetSelf(c *gin.Context) {
 		"status":            user.Status,
 		"email":             user.Email,
 		"github_id":         user.GitHubId,
+		"discord_id":        user.DiscordId,
 		"oidc_id":           user.OidcId,
 		"wechat_id":         user.WeChatId,
 		"telegram_id":       user.TelegramId,

+ 1 - 0
docs/api/web_api.md

@@ -42,6 +42,7 @@
 | 方法 | 路径 | 鉴权 | 说明 |
 |------|------|------|------|
 | GET | /api/oauth/github | 公开 | GitHub OAuth 跳转 |
+| GET | /api/oauth/discord | 公开 | Discord 通用 OAuth 跳转 |
 | GET | /api/oauth/oidc | 公开 | OIDC 通用 OAuth 跳转 |
 | GET | /api/oauth/linuxdo | 公开 | LinuxDo OAuth 跳转 |
 | GET | /api/oauth/wechat | 公开 | 微信扫码登录跳转 |

+ 9 - 0
model/option.go

@@ -38,6 +38,7 @@ func InitOptionMap() {
 	common.OptionMap["PasswordRegisterEnabled"] = strconv.FormatBool(common.PasswordRegisterEnabled)
 	common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled)
 	common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled)
+	common.OptionMap["DiscordOAuthEnabled"] = strconv.FormatBool(common.DiscordOAuthEnabled)
 	common.OptionMap["LinuxDOOAuthEnabled"] = strconv.FormatBool(common.LinuxDOOAuthEnabled)
 	common.OptionMap["TelegramOAuthEnabled"] = strconv.FormatBool(common.TelegramOAuthEnabled)
 	common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled)
@@ -95,6 +96,8 @@ func InitOptionMap() {
 	common.OptionMap["PayMethods"] = operation_setting.PayMethods2JsonString()
 	common.OptionMap["GitHubClientId"] = ""
 	common.OptionMap["GitHubClientSecret"] = ""
+	common.OptionMap["DiscordClientId"] = ""
+	common.OptionMap["DiscordClientSecret"] = ""
 	common.OptionMap["TelegramBotToken"] = ""
 	common.OptionMap["TelegramBotName"] = ""
 	common.OptionMap["WeChatServerAddress"] = ""
@@ -224,6 +227,8 @@ func updateOptionMap(key string, value string) (err error) {
 			common.EmailVerificationEnabled = boolValue
 		case "GitHubOAuthEnabled":
 			common.GitHubOAuthEnabled = boolValue
+		case "DiscordOAuthEnabled":
+			common.DiscordOAuthEnabled = boolValue
 		case "LinuxDOOAuthEnabled":
 			common.LinuxDOOAuthEnabled = boolValue
 		case "WeChatAuthEnabled":
@@ -360,6 +365,10 @@ func updateOptionMap(key string, value string) (err error) {
 		common.GitHubClientId = value
 	case "GitHubClientSecret":
 		common.GitHubClientSecret = value
+	case "DiscordClientId":
+		common.DiscordClientId = value
+	case "DiscordClientSecret":
+		common.DiscordClientSecret = value
 	case "LinuxDOClientId":
 		common.LinuxDOClientId = value
 	case "LinuxDOClientSecret":

+ 13 - 0
model/user.go

@@ -27,6 +27,7 @@ type User struct {
 	Status           int            `json:"status" gorm:"type:int;default:1"` // enabled, disabled
 	Email            string         `json:"email" gorm:"index" validate:"max=50"`
 	GitHubId         string         `json:"github_id" gorm:"column:github_id;index"`
+	DiscordId        string         `json:"discord_id" gorm:"column:discord_id;index"`
 	OidcId           string         `json:"oidc_id" gorm:"column:oidc_id;index"`
 	WeChatId         string         `json:"wechat_id" gorm:"column:wechat_id;index"`
 	TelegramId       string         `json:"telegram_id" gorm:"column:telegram_id;index"`
@@ -539,6 +540,14 @@ func (user *User) FillUserByGitHubId() error {
 	return nil
 }
 
+func (user *User) FillUserByDiscordId() error {
+	if user.DiscordId == "" {
+		return errors.New("discord id 为空!")
+	}
+	DB.Where(User{DiscordId: user.DiscordId}).First(user)
+	return nil
+}
+
 func (user *User) FillUserByOidcId() error {
 	if user.OidcId == "" {
 		return errors.New("oidc id 为空!")
@@ -578,6 +587,10 @@ func IsGitHubIdAlreadyTaken(githubId string) bool {
 	return DB.Unscoped().Where("github_id = ?", githubId).Find(&User{}).RowsAffected == 1
 }
 
+func IsDiscordIdAlreadyTaken(discordId string) bool {
+	return DB.Where("discord_id = ?", discordId).Find(&User{}).RowsAffected == 1
+}
+
 func IsOidcIdAlreadyTaken(oidcId string) bool {
 	return DB.Where("oidc_id = ?", oidcId).Find(&User{}).RowsAffected == 1
 }

+ 1 - 0
router/api-router.go

@@ -30,6 +30,7 @@ func SetApiRouter(router *gin.Engine) {
 		apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
 		apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
 		apiRouter.GET("/oauth/github", middleware.CriticalRateLimit(), controller.GitHubOAuth)
+		apiRouter.GET("/oauth/discord", middleware.CriticalRateLimit(), controller.DiscordOAuth)
 		apiRouter.GET("/oauth/oidc", middleware.CriticalRateLimit(), controller.OidcAuth)
 		apiRouter.GET("/oauth/linuxdo", middleware.CriticalRateLimit(), controller.LinuxdoOAuth)
 		apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode)

+ 8 - 0
web/src/App.jsx

@@ -192,6 +192,14 @@ function App() {
             </Suspense>
           }
         />
+        <Route
+          path='/oauth/discord'
+          element={
+            <Suspense fallback={<Loading></Loading>} key={location.pathname}>
+              <OAuth2Callback type='discord'></OAuth2Callback>
+            </Suspense>
+          }
+        />
         <Route
           path='/oauth/oidc'
           element={

+ 33 - 0
web/src/components/auth/LoginForm.jsx

@@ -30,6 +30,7 @@ import {
   getSystemName,
   setUserData,
   onGitHubOAuthClicked,
+  onDiscordOAuthClicked,
   onOIDCClicked,
   onLinuxDOOAuthClicked,
   prepareCredentialRequestOptions,
@@ -53,6 +54,7 @@ import WeChatIcon from '../common/logo/WeChatIcon';
 import LinuxDoIcon from '../common/logo/LinuxDoIcon';
 import TwoFAVerification from './TwoFAVerification';
 import { useTranslation } from 'react-i18next';
+import { SiDiscord }from 'react-icons/si';
 
 const LoginForm = () => {
   let navigate = useNavigate();
@@ -73,6 +75,7 @@ const LoginForm = () => {
   const [showEmailLogin, setShowEmailLogin] = useState(false);
   const [wechatLoading, setWechatLoading] = useState(false);
   const [githubLoading, setGithubLoading] = useState(false);
+  const [discordLoading, setDiscordLoading] = useState(false);
   const [oidcLoading, setOidcLoading] = useState(false);
   const [linuxdoLoading, setLinuxdoLoading] = useState(false);
   const [emailLoginLoading, setEmailLoginLoading] = useState(false);
@@ -298,6 +301,21 @@ const LoginForm = () => {
     }
   };
 
+  // 包装的Discord登录点击处理
+  const handleDiscordClick = () => {
+    if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
+      showInfo(t('请先阅读并同意用户协议和隐私政策'));
+      return;
+    }
+    setDiscordLoading(true);
+    try {
+      onDiscordOAuthClicked(status.discord_client_id);
+    } finally {
+      // 由于重定向,这里不会执行到,但为了完整性添加
+      setTimeout(() => setDiscordLoading(false), 3000);
+    }
+  };
+
   // 包装的OIDC登录点击处理
   const handleOIDCClick = () => {
     if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
@@ -472,6 +490,19 @@ const LoginForm = () => {
                   </Button>
                 )}
 
+                {status.discord_oauth && (
+                  <Button
+                    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={<SiDiscord style={{ color: '#5865F2', width: '20px', height: '20px' }} />}
+                    onClick={handleDiscordClick}
+                    loading={discordLoading}
+                  >
+                    <span className='ml-3'>{t('使用 Discord 继续')}</span>
+                  </Button>
+                )}
+
                 {status.oidc_enabled && (
                   <Button
                     theme='outline'
@@ -714,6 +745,7 @@ const LoginForm = () => {
               </Form>
 
               {(status.github_oauth ||
+                status.discord_oauth ||
                 status.oidc_enabled ||
                 status.wechat_login ||
                 status.linuxdo_oauth ||
@@ -849,6 +881,7 @@ const LoginForm = () => {
         {showEmailLogin ||
         !(
           status.github_oauth ||
+          status.discord_oauth ||
           status.oidc_enabled ||
           status.wechat_login ||
           status.linuxdo_oauth ||

+ 27 - 0
web/src/components/auth/RegisterForm.jsx

@@ -28,6 +28,7 @@ import {
   updateAPI,
   getSystemName,
   setUserData,
+  onDiscordOAuthClicked,
 } from '../../helpers';
 import Turnstile from 'react-turnstile';
 import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
@@ -51,6 +52,7 @@ import WeChatIcon from '../common/logo/WeChatIcon';
 import TelegramLoginButton from 'react-telegram-login/src';
 import { UserContext } from '../../context/User';
 import { useTranslation } from 'react-i18next';
+import { SiDiscord } from 'react-icons/si';
 
 const RegisterForm = () => {
   let navigate = useNavigate();
@@ -72,6 +74,7 @@ const RegisterForm = () => {
   const [showEmailRegister, setShowEmailRegister] = useState(false);
   const [wechatLoading, setWechatLoading] = useState(false);
   const [githubLoading, setGithubLoading] = useState(false);
+  const [discordLoading, setDiscordLoading] = useState(false);
   const [oidcLoading, setOidcLoading] = useState(false);
   const [linuxdoLoading, setLinuxdoLoading] = useState(false);
   const [emailRegisterLoading, setEmailRegisterLoading] = useState(false);
@@ -264,6 +267,15 @@ const RegisterForm = () => {
     }
   };
 
+  const handleDiscordClick = () => {
+    setDiscordLoading(true);
+    try {
+      onDiscordOAuthClicked(status.discord_client_id);
+    } finally {
+      setTimeout(() => setDiscordLoading(false), 3000);
+    }
+  };
+
   const handleOIDCClick = () => {
     setOidcLoading(true);
     try {
@@ -377,6 +389,19 @@ const RegisterForm = () => {
                   </Button>
                 )}
 
+                {status.discord_oauth && (
+                  <Button
+                    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={<SiDiscord style={{ color: '#5865F2', width: '20px', height: '20px' }} />}
+                    onClick={handleDiscordClick}
+                    loading={discordLoading}
+                  >
+                    <span className='ml-3'>{t('使用 Discord 继续')}</span>
+                  </Button>
+                )}
+
                 {status.oidc_enabled && (
                   <Button
                     theme='outline'
@@ -591,6 +616,7 @@ const RegisterForm = () => {
               </Form>
 
               {(status.github_oauth ||
+                status.discord_oauth ||
                 status.oidc_enabled ||
                 status.wechat_login ||
                 status.linuxdo_oauth ||
@@ -686,6 +712,7 @@ const RegisterForm = () => {
         {showEmailRegister ||
         !(
           status.github_oauth ||
+          status.discord_oauth ||
           status.oidc_enabled ||
           status.wechat_login ||
           status.linuxdo_oauth ||

+ 65 - 0
web/src/components/settings/SystemSetting.jsx

@@ -52,6 +52,9 @@ const SystemSetting = () => {
     GitHubOAuthEnabled: '',
     GitHubClientId: '',
     GitHubClientSecret: '',
+    DiscordOAuthEnabled: '',
+    DiscordClientId: '',
+    DiscordClientSecret: '',
     'oidc.enabled': '',
     'oidc.client_id': '',
     'oidc.client_secret': '',
@@ -179,6 +182,7 @@ const SystemSetting = () => {
           case 'EmailAliasRestrictionEnabled':
           case 'SMTPSSLEnabled':
           case 'LinuxDOOAuthEnabled':
+          case 'DiscordOAuthEnabled':
           case 'oidc.enabled':
           case 'passkey.enabled':
           case 'passkey.allow_insecure_origin':
@@ -473,6 +477,27 @@ const SystemSetting = () => {
     }
   };
 
+  const submitDiscordOAuth = async () => {
+    const options = [];
+
+    if (originInputs['DiscordClientId'] !== inputs.DiscordClientId) {
+      options.push({ key: 'DiscordClientId', value: inputs.DiscordClientId });
+    }
+    if (
+      originInputs['DiscordClientSecret'] !== inputs.DiscordClientSecret &&
+      inputs.DiscordClientSecret !== ''
+    ) {
+      options.push({
+        key: 'DiscordClientSecret',
+        value: inputs.DiscordClientSecret,
+      });
+    }
+
+    if (options.length > 0) {
+      await updateOptions(options);
+    }
+  };
+
   const submitOIDCSettings = async () => {
     if (inputs['oidc.well_known'] && inputs['oidc.well_known'] !== '') {
       if (
@@ -1014,6 +1039,15 @@ const SystemSetting = () => {
                       >
                         {t('允许通过 GitHub 账户登录 & 注册')}
                       </Form.Checkbox>
+                      <Form.Checkbox
+                        field='DiscordOAuthEnabled'
+                        noLabel
+                        onChange={(e) =>
+                          handleCheckboxChange('DiscordOAuthEnabled', e)
+                        }
+                      >
+                        {t('允许通过 Discord 账户登录 & 注册')}
+                      </Form.Checkbox>
                       <Form.Checkbox
                         field='LinuxDOOAuthEnabled'
                         noLabel
@@ -1410,6 +1444,37 @@ const SystemSetting = () => {
                   </Button>
                 </Form.Section>
               </Card>
+              <Card>
+                <Form.Section text={t('配置 Discord OAuth')}>
+                  <Text>{t('用以支持通过 Discord 进行登录注册')}</Text>
+                  <Banner
+                    type='info'
+                    description={`${t('Homepage URL 填')} ${inputs.ServerAddress ? inputs.ServerAddress : t('网站地址')},${t('Authorization callback URL 填')} ${inputs.ServerAddress ? inputs.ServerAddress : t('网站地址')}/oauth/discord`}
+                    style={{ marginBottom: 20, marginTop: 16 }}
+                  />
+                  <Row
+                    gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
+                  >
+                    <Col xs={24} sm={24} md={12} lg={12} xl={12}>
+                      <Form.Input
+                        field='DiscordClientId'
+                        label={t('Discord Client ID')}
+                      />
+                    </Col>
+                    <Col xs={24} sm={24} md={12} lg={12} xl={12}>
+                      <Form.Input
+                        field='DiscordClientSecret'
+                        label={t('Discord Client Secret')}
+                        type='password'
+                        placeholder={t('敏感信息不会发送到前端显示')}
+                      />
+                    </Col>
+                  </Row>
+                  <Button onClick={submitDiscordOAuth}>
+                    {t('保存 Discord OAuth 设置')}
+                  </Button>
+                </Form.Section>
+              </Card>
               <Card>
                 <Form.Section text={t('配置 Linux DO OAuth')}>
                   <Text>

+ 43 - 1
web/src/components/settings/personal/cards/AccountManagement.jsx

@@ -38,13 +38,14 @@ import {
   IconLock,
   IconDelete,
 } from '@douyinfe/semi-icons';
-import { SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
+import { SiTelegram, SiWechat, SiLinux, SiDiscord } from 'react-icons/si';
 import { UserPlus, ShieldCheck } from 'lucide-react';
 import TelegramLoginButton from 'react-telegram-login';
 import {
   onGitHubOAuthClicked,
   onOIDCClicked,
   onLinuxDOOAuthClicked,
+  onDiscordOAuthClicked,
 } from '../../../../helpers';
 import TwoFASetting from '../components/TwoFASetting';
 
@@ -247,6 +248,47 @@ const AccountManagement = ({
                 </div>
               </Card>
 
+              {/* Discord绑定 */}
+              <Card 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'>
+                      <SiDiscord
+                        size={20}
+                        className='text-slate-600 dark:text-slate-300'
+                      />
+                    </div>
+                    <div className='flex-1 min-w-0'>
+                      <div className='font-medium text-gray-900'>
+                        {t('Discord')}
+                      </div>
+                      <div className='text-sm text-gray-500 truncate'>
+                        {renderAccountInfo(
+                          userState.user?.discord_id,
+                          t('Discord ID'),
+                        )}
+                      </div>
+                    </div>
+                  </div>
+                  <div className='flex-shrink-0'>
+                    <Button
+                      type='primary'
+                      theme='outline'
+                      size='small'
+                      onClick={() =>
+                        onDiscordOAuthClicked(status.discord_client_id)
+                      }
+                      disabled={
+                        isBound(userState.user?.discord_id) ||
+                        !status.discord_oauth
+                      }
+                    >
+                      {status.discord_oauth ? t('绑定') : t('未启用')}
+                    </Button>
+                  </div>
+                </div>
+              </Card>
+
               {/* OIDC绑定 */}
               <Card className='!rounded-xl'>
                 <div className='flex items-center justify-between gap-3'>

+ 2 - 0
web/src/components/table/users/modals/EditUserModal.jsx

@@ -72,6 +72,7 @@ const EditUserModal = (props) => {
     password: '',
     github_id: '',
     oidc_id: '',
+    discord_id: '',
     wechat_id: '',
     telegram_id: '',
     email: '',
@@ -332,6 +333,7 @@ const EditUserModal = (props) => {
                   <Row gutter={12}>
                     {[
                       'github_id',
+                      'discord_id',
                       'oidc_id',
                       'wechat_id',
                       'email',

+ 11 - 0
web/src/helpers/api.js

@@ -231,6 +231,17 @@ export async function getOAuthState() {
   }
 }
 
+export async function onDiscordOAuthClicked(client_id) {
+  const state = await getOAuthState();
+  if (!state) return;
+  const redirect_uri = `${window.location.origin}/oauth/discord`;
+  const response_type = 'code';
+  const scope = 'identify+openid';
+  window.open(
+    `https://discord.com/oauth2/authorize?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`,
+  );
+}
+
 export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) {
   const state = await getOAuthState();
   if (!state) return;

+ 1 - 0
web/src/i18n/locales/zh.json

@@ -257,6 +257,7 @@
     "余额充值管理": "余额充值管理",
     "你似乎并没有修改什么": "你似乎并没有修改什么",
     "使用 GitHub 继续": "使用 GitHub 继续",
+    "使用 Discord 继续": "使用 Discord 继续",
     "使用 JSON 对象格式,格式为:{\"组名\": [最多请求次数, 最多请求完成次数]}": "使用 JSON 对象格式,格式为:{\"组名\": [最多请求次数, 最多请求完成次数]}",
     "使用 LinuxDO 继续": "使用 LinuxDO 继续",
     "使用 OIDC 继续": "使用 OIDC 继续",