فهرست منبع

feat: add oidc support

wzxjohn 1 سال پیش
والد
کامیت
c433af284c

+ 8 - 0
common/constants.go

@@ -43,6 +43,7 @@ var PasswordLoginEnabled = true
 var PasswordRegisterEnabled = true
 var EmailVerificationEnabled = false
 var GitHubOAuthEnabled = false
+var OIDCEnabled = false
 var LinuxDOOAuthEnabled = false
 var WeChatAuthEnabled = false
 var TelegramOAuthEnabled = false
@@ -78,6 +79,13 @@ var SMTPToken = ""
 var GitHubClientId = ""
 var GitHubClientSecret = ""
 
+var OIDCClientId = ""
+var OIDCClientSecret = ""
+var OIDCWellKnown = ""
+var OIDCAuthorizationEndpoint = ""
+var OIDCTokenEndpoint = ""
+var OIDCUserInfoEndpoint = ""
+
 var LinuxDOClientId = ""
 var LinuxDOClientSecret = ""
 

+ 37 - 34
controller/misc.go

@@ -34,40 +34,43 @@ func GetStatus(c *gin.Context) {
 		"success": true,
 		"message": "",
 		"data": gin.H{
-			"version":                  common.Version,
-			"start_time":               common.StartTime,
-			"email_verification":       common.EmailVerificationEnabled,
-			"github_oauth":             common.GitHubOAuthEnabled,
-			"github_client_id":         common.GitHubClientId,
-			"linuxdo_oauth":            common.LinuxDOOAuthEnabled,
-			"linuxdo_client_id":        common.LinuxDOClientId,
-			"telegram_oauth":           common.TelegramOAuthEnabled,
-			"telegram_bot_name":        common.TelegramBotName,
-			"system_name":              common.SystemName,
-			"logo":                     common.Logo,
-			"footer_html":              common.Footer,
-			"wechat_qrcode":            common.WeChatAccountQRCodeImageURL,
-			"wechat_login":             common.WeChatAuthEnabled,
-			"server_address":           setting.ServerAddress,
-			"price":                    setting.Price,
-			"min_topup":                setting.MinTopUp,
-			"turnstile_check":          common.TurnstileCheckEnabled,
-			"turnstile_site_key":       common.TurnstileSiteKey,
-			"top_up_link":              common.TopUpLink,
-			"docs_link":                operation_setting.GetGeneralSetting().DocsLink,
-			"quota_per_unit":           common.QuotaPerUnit,
-			"display_in_currency":      common.DisplayInCurrencyEnabled,
-			"enable_batch_update":      common.BatchUpdateEnabled,
-			"enable_drawing":           common.DrawingEnabled,
-			"enable_task":              common.TaskEnabled,
-			"enable_data_export":       common.DataExportEnabled,
-			"data_export_default_time": common.DataExportDefaultTime,
-			"default_collapse_sidebar": common.DefaultCollapseSidebar,
-			"enable_online_topup":      setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
-			"mj_notify_enabled":        setting.MjNotifyEnabled,
-			"chats":                    setting.Chats,
-			"demo_site_enabled":        operation_setting.DemoSiteEnabled,
-			"self_use_mode_enabled":    operation_setting.SelfUseModeEnabled,
+			"version":                     common.Version,
+			"start_time":                  common.StartTime,
+			"email_verification":          common.EmailVerificationEnabled,
+			"github_oauth":                common.GitHubOAuthEnabled,
+			"github_client_id":            common.GitHubClientId,
+			"linuxdo_oauth":               common.LinuxDOOAuthEnabled,
+			"linuxdo_client_id":           common.LinuxDOClientId,
+			"telegram_oauth":              common.TelegramOAuthEnabled,
+			"telegram_bot_name":           common.TelegramBotName,
+			"system_name":                 common.SystemName,
+			"logo":                        common.Logo,
+			"footer_html":                 common.Footer,
+			"wechat_qrcode":               common.WeChatAccountQRCodeImageURL,
+			"wechat_login":                common.WeChatAuthEnabled,
+			"server_address":              setting.ServerAddress,
+			"price":                       setting.Price,
+			"min_topup":                   setting.MinTopUp,
+			"turnstile_check":             common.TurnstileCheckEnabled,
+			"turnstile_site_key":          common.TurnstileSiteKey,
+			"top_up_link":                 common.TopUpLink,
+			"docs_link":                   operation_setting.GetGeneralSetting().DocsLink,
+			"quota_per_unit":              common.QuotaPerUnit,
+			"display_in_currency":         common.DisplayInCurrencyEnabled,
+			"enable_batch_update":         common.BatchUpdateEnabled,
+			"enable_drawing":              common.DrawingEnabled,
+			"enable_task":                 common.TaskEnabled,
+			"enable_data_export":          common.DataExportEnabled,
+			"data_export_default_time":    common.DataExportDefaultTime,
+			"default_collapse_sidebar":    common.DefaultCollapseSidebar,
+			"enable_online_topup":         setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
+			"mj_notify_enabled":           setting.MjNotifyEnabled,
+			"chats":                       setting.Chats,
+			"demo_site_enabled":           operation_setting.DemoSiteEnabled,
+			"self_use_mode_enabled":       operation_setting.SelfUseModeEnabled,
+			"oidc":                        common.OIDCEnabled,
+			"oidc_client_id":              common.OIDCClientId,
+			"oidc_authorization_endpoint": common.OIDCAuthorizationEndpoint,
 		},
 	})
 	return

+ 239 - 0
controller/oidc.go

@@ -0,0 +1,239 @@
+package controller
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"one-api/common"
+	"one-api/model"
+	"one-api/setting"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-gonic/gin"
+)
+
+type OidcResponse 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 getOidcUserInfoByCode(code string) (*OidcUser, error) {
+	if code == "" {
+		return nil, errors.New("无效的参数")
+	}
+
+	values := url.Values{}
+	values.Set("client_id", common.OIDCClientId)
+	values.Set("client_secret", common.OIDCClientSecret)
+	values.Set("code", code)
+	values.Set("grant_type", "authorization_code")
+	values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", setting.ServerAddress))
+	formData := values.Encode()
+	req, err := http.NewRequest("POST", common.OIDCTokenEndpoint, 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("无法连接至 OIDC 服务器,请稍后重试!")
+	}
+	defer res.Body.Close()
+	var oidcResponse OidcResponse
+	err = json.NewDecoder(res.Body).Decode(&oidcResponse)
+	if err != nil {
+		return nil, err
+	}
+
+	if oidcResponse.AccessToken == "" {
+		common.SysError("OIDC 获取 Token 失败,请检查设置!")
+		return nil, errors.New("OIDC 获取 Token 失败,请检查设置!")
+	}
+
+	req, err = http.NewRequest("GET", common.OIDCUserInfoEndpoint, nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Authorization", "Bearer "+oidcResponse.AccessToken)
+	res2, err := client.Do(req)
+	if err != nil {
+		common.SysLog(err.Error())
+		return nil, errors.New("无法连接至 OIDC 服务器,请稍后重试!")
+	}
+	defer res2.Body.Close()
+	if res2.StatusCode != http.StatusOK {
+		common.SysError("OIDC 获取用户信息失败!请检查设置!")
+		return nil, errors.New("OIDC 获取用户信息失败!请检查设置!")
+	}
+
+	var oidcUser OidcUser
+	err = json.NewDecoder(res2.Body).Decode(&oidcUser)
+	if err != nil {
+		return nil, err
+	}
+	if oidcUser.OpenID == "" || oidcUser.Email == "" {
+		common.SysError("OIDC 获取用户信息为空!请检查设置!")
+		return nil, errors.New("OIDC 获取用户信息为空!请检查设置!")
+	}
+	return &oidcUser, nil
+}
+
+func OidcAuth(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 {
+		OidcBind(c)
+		return
+	}
+	if !common.OIDCEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "管理员未开启通过 OIDC 登录以及注册",
+		})
+		return
+	}
+	code := c.Query("code")
+	oidcUser, err := getOidcUserInfoByCode(code)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	user := model.User{
+		OidcId: oidcUser.OpenID,
+	}
+	if model.IsOidcIdAlreadyTaken(user.OidcId) {
+		err := user.FillUserByOidcId()
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
+	} else {
+		if common.RegisterEnabled {
+			user.Email = oidcUser.Email
+			if oidcUser.PreferredUsername != "" {
+				user.Username = oidcUser.PreferredUsername
+			} else {
+				user.Username = "oidc_" + strconv.Itoa(model.GetMaxUserId()+1)
+			}
+			if oidcUser.Name != "" {
+				user.DisplayName = oidcUser.Name
+			} else {
+				user.DisplayName = "OIDC 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 OidcBind(c *gin.Context) {
+	if !common.OIDCEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "管理员未开启通过 OIDC 登录以及注册",
+		})
+		return
+	}
+	code := c.Query("code")
+	oidcUser, err := getOidcUserInfoByCode(code)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	user := model.User{
+		OidcId: oidcUser.OpenID,
+	}
+	if model.IsOidcIdAlreadyTaken(user.OidcId) {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "该 OIDC 账户已被绑定",
+		})
+		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 {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	user.OidcId = oidcUser.OpenID
+	err = user.Update(false)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "bind",
+	})
+	return
+}

+ 7 - 0
controller/option.go

@@ -51,6 +51,13 @@ func UpdateOption(c *gin.Context) {
 			})
 			return
 		}
+	case "OIDCEnabled":
+		if option.Value == "true" && common.OIDCClientId == "" {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "无法启用 OIDC 登录,请先填入 OIDC Client Id 以及 OIDC Client Secret!",
+			})
+		}
 	case "LinuxDOOAuthEnabled":
 		if option.Value == "true" && common.LinuxDOClientId == "" {
 			c.JSON(http.StatusOK, gin.H{

+ 5 - 5
go.mod

@@ -28,9 +28,9 @@ require (
 	github.com/samber/lo v1.39.0
 	github.com/shirou/gopsutil v3.21.11+incompatible
 	github.com/shopspring/decimal v1.4.0
-	golang.org/x/crypto v0.27.0
+	golang.org/x/crypto v0.35.0
 	golang.org/x/image v0.23.0
-	golang.org/x/net v0.28.0
+	golang.org/x/net v0.35.0
 	gorm.io/driver/mysql v1.4.3
 	gorm.io/driver/postgres v1.5.2
 	gorm.io/gorm v1.25.2
@@ -84,9 +84,9 @@ require (
 	github.com/yusufpapurcu/wmi v1.2.3 // indirect
 	golang.org/x/arch v0.12.0 // indirect
 	golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
-	golang.org/x/sync v0.10.0 // indirect
-	golang.org/x/sys v0.27.0 // indirect
-	golang.org/x/text v0.21.0 // indirect
+	golang.org/x/sync v0.11.0 // indirect
+	golang.org/x/sys v0.30.0 // indirect
+	golang.org/x/text v0.22.0 // indirect
 	google.golang.org/protobuf v1.34.2 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 	modernc.org/libc v1.22.5 // indirect

+ 10 - 10
go.sum

@@ -217,18 +217,18 @@ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUu
 golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
 golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
 golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
-golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
+golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
+golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
 golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8=
 golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
 golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
 golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
-golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
+golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
+golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
-golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
+golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -239,14 +239,14 @@ golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
-golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
-golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=

+ 21 - 0
model/option.go

@@ -35,6 +35,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["OIDCEnabled"] = strconv.FormatBool(common.OIDCEnabled)
 	common.OptionMap["LinuxDOOAuthEnabled"] = strconv.FormatBool(common.LinuxDOOAuthEnabled)
 	common.OptionMap["TelegramOAuthEnabled"] = strconv.FormatBool(common.TelegramOAuthEnabled)
 	common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled)
@@ -77,6 +78,12 @@ func InitOptionMap() {
 	common.OptionMap["Chats"] = setting.Chats2JsonString()
 	common.OptionMap["GitHubClientId"] = ""
 	common.OptionMap["GitHubClientSecret"] = ""
+	common.OptionMap["OIDCClientId"] = ""
+	common.OptionMap["OIDCClientSecret"] = ""
+	common.OptionMap["OIDCWellKnown"] = ""
+	common.OptionMap["OIDCAuthorizationEndpoint"] = ""
+	common.OptionMap["OIDCTokenEndpoint"] = ""
+	common.OptionMap["OIDCUserInfoEndpoint"] = ""
 	common.OptionMap["TelegramBotToken"] = ""
 	common.OptionMap["TelegramBotName"] = ""
 	common.OptionMap["WeChatServerAddress"] = ""
@@ -200,6 +207,8 @@ func updateOptionMap(key string, value string) (err error) {
 			common.EmailVerificationEnabled = boolValue
 		case "GitHubOAuthEnabled":
 			common.GitHubOAuthEnabled = boolValue
+		case "OIDCEnabled":
+			common.OIDCEnabled = boolValue
 		case "LinuxDOOAuthEnabled":
 			common.LinuxDOOAuthEnabled = boolValue
 		case "WeChatAuthEnabled":
@@ -298,6 +307,18 @@ func updateOptionMap(key string, value string) (err error) {
 		common.GitHubClientId = value
 	case "GitHubClientSecret":
 		common.GitHubClientSecret = value
+	case "OIDCClientId":
+		common.OIDCClientId = value
+	case "OIDCClientSecret":
+		common.OIDCClientSecret = value
+	case "OIDCWellKnown":
+		common.OIDCWellKnown = value
+	case "OIDCAuthorizationEndpoint":
+		common.OIDCAuthorizationEndpoint = value
+	case "OIDCTokenEndpoint":
+		common.OIDCTokenEndpoint = value
+	case "OIDCUserInfoEndpoint":
+		common.OIDCUserInfoEndpoint = value
 	case "LinuxDOClientId":
 		common.LinuxDOClientId = value
 	case "LinuxDOClientSecret":

+ 13 - 1
model/user.go

@@ -9,7 +9,6 @@ import (
 	"strings"
 
 	"github.com/bytedance/gopkg/util/gopool"
-
 	"gorm.io/gorm"
 )
 
@@ -24,6 +23,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"`
+	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"`
 	VerificationCode string         `json:"verification_code" gorm:"-:all"`                                    // this field is only for Email verification, don't save it to database!
@@ -442,6 +442,14 @@ func (user *User) FillUserByGitHubId() error {
 	return nil
 }
 
+func (user *User) FillUserByOidcId() error {
+	if user.OidcId == "" {
+		return errors.New("oidc id 为空!")
+	}
+	DB.Where(User{OidcId: user.OidcId}).First(user)
+	return nil
+}
+
 func (user *User) FillUserByWeChatId() error {
 	if user.WeChatId == "" {
 		return errors.New("WeChat id 为空!")
@@ -473,6 +481,10 @@ func IsGitHubIdAlreadyTaken(githubId string) bool {
 	return DB.Unscoped().Where("github_id = ?", githubId).Find(&User{}).RowsAffected == 1
 }
 
+func IsOidcIdAlreadyTaken(oidcId string) bool {
+	return DB.Where("oidc_id = ?", oidcId).Find(&User{}).RowsAffected == 1
+}
+
 func IsTelegramIdAlreadyTaken(telegramId string) bool {
 	return DB.Unscoped().Where("telegram_id = ?", telegramId).Find(&User{}).RowsAffected == 1
 }

+ 1 - 0
router/api-router.go

@@ -25,6 +25,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/oidc", middleware.CriticalRateLimit(), controller.OidcAuth)
 		apiRouter.GET("/oauth/linuxdo", middleware.CriticalRateLimit(), controller.LinuxdoOAuth)
 		apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode)
 		apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)

+ 8 - 0
web/src/App.js

@@ -160,6 +160,14 @@ function App() {
             </Suspense>
           }
         />
+        <Route
+            path='/oauth/oidc'
+            element={
+                <Suspense fallback={<Loading></Loading>}>
+                    <OAuth2Callback type='oidc'></OAuth2Callback>
+                </Suspense>
+            }
+        />
         <Route
           path='/oauth/linuxdo'
           element={

+ 14 - 1
web/src/components/LoginForm.js

@@ -9,7 +9,7 @@ import {
   showSuccess,
   updateAPI,
 } from '../helpers';
-import { onGitHubOAuthClicked, onLinuxDOOAuthClicked } from './utils';
+import {onGitHubOAuthClicked, onOIDCClicked, onLinuxDOOAuthClicked} from './utils';
 import Turnstile from 'react-turnstile';
 import {
   Button,
@@ -25,6 +25,7 @@ import Text from '@douyinfe/semi-ui/lib/es/typography/text';
 import TelegramLoginButton from 'react-telegram-login';
 
 import { IconGithubLogo, IconAlarm } from '@douyinfe/semi-icons';
+import OIDCIcon from './OIDCIcon.js';
 import WeChatIcon from './WeChatIcon';
 import { setUserData } from '../helpers/data.js';
 import LinuxDoIcon from './LinuxDoIcon.js';
@@ -229,6 +230,7 @@ const LoginForm = () => {
                   </Text>
                 </div>
                 {status.github_oauth ||
+                status.oidc ||
                 status.wechat_login ||
                 status.telegram_oauth ||
                 status.linuxdo_oauth ? (
@@ -254,6 +256,17 @@ const LoginForm = () => {
                       ) : (
                         <></>
                       )}
+                      {status.oidc ? (
+                          <Button
+                              type='primary'
+                              icon={<OIDCIcon />}
+                              onClick={() =>
+                                  onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id)
+                              }
+                          />
+                      ) : (
+                          <></>
+                      )}
                       {status.linuxdo_oauth ? (
                         <Button
                           icon={<LinuxDoIcon />}

+ 22 - 0
web/src/components/OIDCIcon.js

@@ -0,0 +1,22 @@
+import React from 'react';
+import { Icon } from '@douyinfe/semi-ui';
+
+const OIDCIcon = (props) => {
+    function CustomIcon() {
+        return (
+            <svg t="1723135116886" className="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
+                 p-id="10969" width="1em" height="1em">
+                <path
+                    d="M512 960C265 960 64 759 64 512S265 64 512 64s448 201 448 448-201 448-448 448z m0-882.6c-239.7 0-434.6 195-434.6 434.6s195 434.6 434.6 434.6 434.6-195 434.6-434.6S751.7 77.4 512 77.4z"
+                    p-id="10970" fill="#2c2c2c" stroke="#2c2c2c" stroke-width="60"></path>
+                <path
+                    d="M197.7 512c0-78.3 31.6-98.8 87.2-98.8 56.2 0 87.2 20.5 87.2 98.8s-31 98.8-87.2 98.8c-55.7 0-87.2-20.5-87.2-98.8z m130.4 0c0-46.8-7.8-64.5-43.2-64.5-35.2 0-42.9 17.7-42.9 64.5 0 47.1 7.8 63.7 42.9 63.7 35.4 0 43.2-16.6 43.2-63.7zM409.7 415.9h42.1V608h-42.1V415.9zM653.9 512c0 74.2-37.1 96.1-93.6 96.1h-65.9V415.9h65.9c56.5 0 93.6 16.1 93.6 96.1z m-43.5 0c0-49.3-17.7-60.6-52.3-60.6h-21.6v120.7h21.6c35.4 0 52.3-13.3 52.3-60.1zM686.5 512c0-74.2 36.3-98.8 92.7-98.8 18.3 0 33.2 2.2 44.8 6.4v36.3c-11.9-4.2-26-6.6-42.1-6.6-34.6 0-49.8 15.5-49.8 62.6 0 50.1 15.2 62.6 49.3 62.6 15.8 0 30.2-2.2 44.8-7.5v36c-11.3 4.7-28.5 8-46.8 8-56.1-0.2-92.9-18.7-92.9-99z"
+                    p-id="10971" fill="#2c2c2c" stroke="#2c2c2c" stroke-width="20"></path>
+            </svg>
+        );
+    }
+
+    return <Icon svg={<CustomIcon />} />;
+};
+
+export default OIDCIcon;

+ 31 - 1
web/src/components/PersonalSetting.js

@@ -10,7 +10,7 @@ import {
 } from '../helpers';
 import Turnstile from 'react-turnstile';
 import {UserContext} from '../context/User';
-import {onGitHubOAuthClicked, onLinuxDOOAuthClicked} from './utils';
+import {onGitHubOAuthClicked, onOIDCClicked, onLinuxDOOAuthClicked} from './utils';
 import {
     Avatar,
     Banner,
@@ -640,6 +640,36 @@ const PersonalSetting = () => {
                                     </div>
                                 </div>
                             </div>
+                            <div style={{marginTop: 10}}>
+                                <Typography.Text strong>{t('OIDC')}</Typography.Text>
+                                <div
+                                    style={{display: 'flex', justifyContent: 'space-between'}}
+                                >
+                                    <div>
+                                        <Input
+                                            value={
+                                                userState.user && userState.user.oidc_id !== ''
+                                                    ? userState.user.oidc_id
+                                                    : t('未绑定')
+                                            }
+                                            readonly={true}
+                                        ></Input>
+                                    </div>
+                                    <div>
+                                        <Button
+                                            onClick={() => {
+                                                onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
+                                            }}
+                                            disabled={
+                                                (userState.user && userState.user.oidc_id !== '') ||
+                                                !status.oidc
+                                            }
+                                        >
+                                            {status.oidc ? t('绑定') : t('未启用')}
+                                        </Button>
+                                    </div>
+                                </div>
+                            </div>
                             <div style={{marginTop: 10}}>
                                 <Typography.Text strong>{t('Telegram')}</Typography.Text>
                                 <div

+ 14 - 1
web/src/components/RegisterForm.js

@@ -6,7 +6,8 @@ import { Button, Card, Divider, Form, Icon, Layout, Modal } from '@douyinfe/semi
 import Title from '@douyinfe/semi-ui/lib/es/typography/title';
 import Text from '@douyinfe/semi-ui/lib/es/typography/text';
 import { IconGithubLogo } from '@douyinfe/semi-icons';
-import { onGitHubOAuthClicked, onLinuxDOOAuthClicked } from './utils.js';
+import {onGitHubOAuthClicked, onLinuxDOOAuthClicked, onOIDCClicked} from './utils.js';
+import OIDCIcon from "./OIDCIcon.js";
 import LinuxDoIcon from './LinuxDoIcon.js';
 import WeChatIcon from './WeChatIcon.js';
 import TelegramLoginButton from 'react-telegram-login/src';
@@ -262,6 +263,7 @@ const RegisterForm = () => {
                   </Text>
                 </div>
                 {status.github_oauth ||
+                status.oidc ||
                 status.wechat_login ||
                 status.telegram_oauth ||
                 status.linuxdo_oauth ? (
@@ -287,6 +289,17 @@ const RegisterForm = () => {
                       ) : (
                         <></>
                       )}
+                      {status.oidc ? (
+                          <Button
+                              type='primary'
+                              icon={<OIDCIcon />}
+                              onClick={() =>
+                                  onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id)
+                              }
+                          />
+                      ) : (
+                          <></>
+                      )}
                       {status.linuxdo_oauth ? (
                         <Button
                           icon={<LinuxDoIcon />}

+ 120 - 1
web/src/components/SystemSetting.js

@@ -20,6 +20,13 @@ const SystemSetting = () => {
     GitHubOAuthEnabled: '',
     GitHubClientId: '',
     GitHubClientSecret: '',
+    OIDCEnabled: '',
+    OIDCClientId: '',
+    OIDCClientSecret: '',
+    OIDCWellKnown: '',
+    OIDCAuthorizationEndpoint: '',
+    OIDCTokenEndpoint: '',
+    OIDCUserInfoEndpoint: '',
     Notice: '',
     SMTPServer: '',
     SMTPPort: '',
@@ -106,6 +113,7 @@ const SystemSetting = () => {
       case 'PasswordRegisterEnabled':
       case 'EmailVerificationEnabled':
       case 'GitHubOAuthEnabled':
+      case 'OIDCEnabled':
       case 'LinuxDOOAuthEnabled':
       case 'WeChatAuthEnabled':
       case 'TelegramOAuthEnabled':
@@ -159,6 +167,12 @@ const SystemSetting = () => {
       name === 'PayAddress' ||
       name === 'GitHubClientId' ||
       name === 'GitHubClientSecret' ||
+      name === 'OIDCWellKnown' ||
+      name === 'OIDCClientId' ||
+      name === 'OIDCClientSecret' ||
+      name === 'OIDCAuthorizationEndpoint' ||
+      name === 'OIDCTokenEndpoint' ||
+      name === 'OIDCUserInfoEndpoint' ||
       name === 'WeChatServerAddress' ||
       name === 'WeChatServerToken' ||
       name === 'WeChatAccountQRCodeImageURL' ||
@@ -286,6 +300,43 @@ const SystemSetting = () => {
     }
   };
 
+  const submitOIDCSettings = async () => {
+    if (inputs.OIDCWellKnown !== '') {
+      if (!inputs.OIDCWellKnown.startsWith('http://') && !inputs.OIDCWellKnown.startsWith('https://')) {
+        showError('Well-Known URL 必须以 http:// 或 https:// 开头');
+        return;
+      }
+      try {
+        const res = await API.get(inputs.OIDCWellKnown);
+        inputs.OIDCAuthorizationEndpoint = res.data['authorization_endpoint'];
+        inputs.OIDCTokenEndpoint = res.data['token_endpoint'];
+        inputs.OIDCUserInfoEndpoint = res.data['userinfo_endpoint'];
+        showSuccess('获取 OIDC 配置成功!');
+      } catch (err) {
+        showError("获取 OIDC 配置失败,请检查网络状况和 Well-Known URL 是否正确");
+      }
+    }
+
+    if (originInputs['OIDCWellKnown'] !== inputs.OIDCWellKnown) {
+      await updateOption('OIDCWellKnown', inputs.OIDCWellKnown);
+    }
+    if (originInputs['OIDCClientId'] !== inputs.OIDCClientId) {
+      await updateOption('OIDCClientId', inputs.OIDCClientId);
+    }
+    if (originInputs['OIDCClientSecret'] !== inputs.OIDCClientSecret && inputs.OIDCClientSecret !== '') {
+      await updateOption('OIDCClientSecret', inputs.OIDCClientSecret);
+    }
+    if (originInputs['OIDCAuthorizationEndpoint'] !== inputs.OIDCAuthorizationEndpoint) {
+      await updateOption('OIDCAuthorizationEndpoint', inputs.OIDCAuthorizationEndpoint);
+    }
+    if (originInputs['OIDCTokenEndpoint'] !== inputs.OIDCTokenEndpoint) {
+      await updateOption('OIDCTokenEndpoint', inputs.OIDCTokenEndpoint);
+    }
+    if (originInputs['OIDCUserInfoEndpoint'] !== inputs.OIDCUserInfoEndpoint) {
+      await updateOption('OIDCUserInfoEndpoint', inputs.OIDCUserInfoEndpoint);
+    }
+  }
+
   const submitTelegramSettings = async () => {
     // await updateOption('TelegramOAuthEnabled', inputs.TelegramOAuthEnabled);
     await updateOption('TelegramBotToken', inputs.TelegramBotToken);
@@ -370,7 +421,7 @@ const SystemSetting = () => {
           </Header>
           <Message info>
             注意:代理功能仅对图片请求和 Webhook 请求生效,不会影响其他 API 请求。如需配置 API 请求代理,请参考
-            <a 
+            <a
               href='https://github.com/Calcium-Ion/new-api/blob/main/docs/channel/other_setting.md'
               target='_blank'
               rel='noreferrer'
@@ -518,6 +569,12 @@ const SystemSetting = () => {
               name='GitHubOAuthEnabled'
               onChange={handleInputChange}
             />
+            <Form.Checkbox
+                checked={inputs.OIDCEnabled === 'true'}
+                label='允许通过 OIDC 登录 & 注册'
+                name='OIDCEnabled'
+                onChange={handleInputChange}
+            />
             <Form.Checkbox
               checked={inputs.LinuxDOOAuthEnabled === 'true'}
               label='允许通过 LinuxDO 账户登录 & 注册'
@@ -864,6 +921,68 @@ const SystemSetting = () => {
           <Form.Button onClick={submitLinuxDOOAuth}>
             保存 LinuxDO OAuth 设置
           </Form.Button>
+          <Divider />
+          <Header as='h3' inverted={isDark}>
+            配置 OIDC
+            <Header.Subheader>
+              用以支持通过 OIDC 登录,例如 Okta、Auth0 等兼容 OIDC 协议的 IdP
+            </Header.Subheader>
+          </Header>
+          <Message>
+            主页链接填 <code>{ inputs.ServerAddress }</code>,
+            重定向 URL 填 <code>{ `${ inputs.ServerAddress }/oauth/oidc` }</code>
+          </Message>
+          <Message>
+            若你的 OIDC Provider 支持 Discovery Endpoint,你可以仅填写 OIDC Well-Known URL,系统会自动获取 OIDC 配置
+          </Message>
+          <Form.Group widths={3}>
+            <Form.Input
+                label='Client ID'
+                name='OIDCClientId'
+                onChange={handleInputChange}
+                value={inputs.OIDCClientId}
+                placeholder='输入 OIDC 的 Client ID'
+            />
+            <Form.Input
+                label='Client Secret'
+                name='OIDCClientSecret'
+                onChange={handleInputChange}
+                type='password'
+                value={inputs.OIDCClientSecret}
+                placeholder='敏感信息不会发送到前端显示'
+            />
+            <Form.Input
+                label='Well-Known URL'
+                name='OIDCWellKnown'
+                onChange={handleInputChange}
+                value={inputs.OIDCWellKnown}
+                placeholder='请输入 OIDC 的 Well-Known URL'
+            />
+            <Form.Input
+                label='Authorization Endpoint'
+                name='OIDCAuthorizationEndpoint'
+                onChange={handleInputChange}
+                value={inputs.OIDCAuthorizationEndpoint}
+                placeholder='输入 OIDC 的 Authorization Endpoint'
+            />
+            <Form.Input
+                label='Token Endpoint'
+                name='OIDCTokenEndpoint'
+                onChange={handleInputChange}
+                value={inputs.OIDCTokenEndpoint}
+                placeholder='输入 OIDC 的 Token Endpoint'
+            />
+            <Form.Input
+                label='Userinfo Endpoint'
+                name='OIDCUserInfoEndpoint'
+                onChange={handleInputChange}
+                value={inputs.OIDCUserInfoEndpoint}
+                placeholder='输入 OIDC 的 Userinfo Endpoint'
+            />
+          </Form.Group>
+          <Form.Button onClick={submitOIDCSettings}>
+            保存 OIDC 设置
+          </Form.Button>
         </Form>
       </Grid.Column>
     </Grid>

+ 15 - 0
web/src/components/utils.js

@@ -16,6 +16,21 @@ export async function getOAuthState() {
   }
 }
 
+export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) {
+  const state = await getOAuthState();
+  if (!state) return;
+  const redirect_uri = `${window.location.origin}/oauth/oidc`;
+  const response_type = "code";
+  const scope = "openid profile email";
+  const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`;
+  if (openInNewTab) {
+    window.open(url);
+  } else
+  {
+    window.location.href = url;
+  }
+}
+
 export async function onGitHubOAuthClicked(github_client_id) {
   const state = await getOAuthState();
   if (!state) return;

+ 6 - 0
web/src/pages/Home/index.js

@@ -151,6 +151,12 @@ const Home = () => {
                       ? t('已启用')
                       : t('未启用')}
                   </p>
+                  <p>
+                    {t('OIDC 身份验证')}:
+                    {statusState?.status?.oidc === true
+                        ? t('已启用')
+                        : t('未启用')}
+                  </p>
                   <p>
                     {t('微信身份验证')}:
                     {statusState?.status?.wechat_login === true

+ 11 - 0
web/src/pages/User/EditUser.js

@@ -26,6 +26,7 @@ const EditUser = (props) => {
     display_name: '',
     password: '',
     github_id: '',
+    oidc_id: '',
     wechat_id: '',
     email: '',
     quota: 0,
@@ -37,6 +38,7 @@ const EditUser = (props) => {
     display_name,
     password,
     github_id,
+    oidc_id,
     wechat_id,
     telegram_id,
     email,
@@ -232,6 +234,15 @@ const EditUser = (props) => {
             placeholder={t('此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改')}
             readonly
           />
+          <div style={{ marginTop: 20 }}>
+            <Typography.Text>{t('`已绑定的 OIDC 账户')}</Typography.Text>
+          </div>
+          <Input
+              name='oidc_id'
+              value={oidc_id}
+              placeholder={t('此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改')}
+              readonly
+          />
           <div style={{ marginTop: 20 }}>
             <Typography.Text>{t('已绑定的微信账户')}</Typography.Text>
           </div>