From ca49b3fbd0f161e8e1b80a513294c9d3898f9f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=9D=BF=E8=B6=85?= Date: Tue, 7 Apr 2026 12:49:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Google=20OAuth=20?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Google OAuth 提供者实现,包括令牌交换和用户信息获取 - 在用户模型中添加 GoogleId 字段及相关查询方法 - 添加系统设置页面用于配置 Google OAuth 客户端信息 - 在前端登录和注册页面添加 Google 登录按钮 - 在用户状态更新时同步 GoogleId 字段 --- controller/misc.go | 2 + controller/oauth.go | 1 + controller/option.go | 8 + model/user.go | 14 ++ oauth/google.go | 159 ++++++++++++++++++ setting/system_setting/google.go | 19 +++ web/src/components/auth/LoginForm.jsx | 40 ++++- web/src/components/auth/RegisterForm.jsx | 43 ++++- web/src/components/settings/SystemSetting.jsx | 66 ++++++++ web/src/helpers/api.js | 11 ++ 10 files changed, 356 insertions(+), 7 deletions(-) create mode 100644 oauth/google.go create mode 100644 setting/system_setting/google.go diff --git a/controller/misc.go b/controller/misc.go index 519caed57b81..ad6665694c6c 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -54,6 +54,8 @@ func GetStatus(c *gin.Context) { "email_verification": common.EmailVerificationEnabled, "github_oauth": common.GitHubOAuthEnabled, "github_client_id": common.GitHubClientId, + "google_oauth": system_setting.GetGoogleSettings().Enabled, + "google_client_id": system_setting.GetGoogleSettings().ClientId, "discord_oauth": system_setting.GetDiscordSettings().Enabled, "discord_client_id": system_setting.GetDiscordSettings().ClientId, "linuxdo_oauth": common.LinuxDOOAuthEnabled, diff --git a/controller/oauth.go b/controller/oauth.go index 9951f22b035f..d26b8a43508e 100644 --- a/controller/oauth.go +++ b/controller/oauth.go @@ -308,6 +308,7 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o provider.SetProviderUserID(user, oauthUser.ProviderUserID) if err := tx.Model(user).Updates(map[string]interface{}{ "github_id": user.GitHubId, + "google_id": user.GoogleId, "discord_id": user.DiscordId, "oidc_id": user.OidcId, "linux_do_id": user.LinuxDOId, diff --git a/controller/option.go b/controller/option.go index ecb1e25e8677..bd538d267ec7 100644 --- a/controller/option.go +++ b/controller/option.go @@ -139,6 +139,14 @@ func UpdateOption(c *gin.Context) { }) return } + case "google.enabled": + if option.Value == "true" && (system_setting.GetGoogleSettings().ClientId == "" || system_setting.GetGoogleSettings().ClientSecret == "") { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用 Google OAuth,请先填入 Google Client Id 以及 Google Client Secret!", + }) + return + } case "oidc.enabled": if option.Value == "true" && system_setting.GetOIDCSettings().ClientId == "" { c.JSON(http.StatusOK, gin.H{ diff --git a/model/user.go b/model/user.go index 1210b5435d04..145e500247e9 100644 --- a/model/user.go +++ b/model/user.go @@ -30,6 +30,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"` + GoogleId string `json:"google_id" gorm:"column:google_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"` @@ -547,6 +548,7 @@ func (user *User) ClearBinding(bindingType string) error { bindingColumnMap := map[string]string{ "email": "email", "github": "github_id", + "google": "google_id", "discord": "discord_id", "oidc": "oidc_id", "wechat": "wechat_id", @@ -633,6 +635,14 @@ func (user *User) FillUserByGitHubId() error { return nil } +func (user *User) FillUserByGoogleId() error { + if user.GoogleId == "" { + return errors.New("Google id 为空!") + } + DB.Where(User{GoogleId: user.GoogleId}).First(user) + return nil +} + // UpdateGitHubId updates the user's GitHub ID (used for migration from login to numeric ID) func (user *User) UpdateGitHubId(newGitHubId string) error { if user.Id == 0 { @@ -688,6 +698,10 @@ func IsGitHubIdAlreadyTaken(githubId string) bool { return DB.Unscoped().Where("github_id = ?", githubId).Find(&User{}).RowsAffected == 1 } +func IsGoogleIdAlreadyTaken(googleId string) bool { + return DB.Unscoped().Where("google_id = ?", googleId).Find(&User{}).RowsAffected == 1 +} + func IsDiscordIdAlreadyTaken(discordId string) bool { return DB.Unscoped().Where("discord_id = ?", discordId).Find(&User{}).RowsAffected == 1 } diff --git a/oauth/google.go b/oauth/google.go new file mode 100644 index 000000000000..011505d2c70e --- /dev/null +++ b/oauth/google.go @@ -0,0 +1,159 @@ +package oauth + +import ( + "context" + "fmt" + "net/http" + "net/url" + "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" +) + +func init() { + Register("google", &GoogleProvider{}) +} + +// GoogleProvider implements OAuth for Google +type GoogleProvider struct{} + +type googleOAuthResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + IDToken string `json:"id_token"` +} + +type googleUser struct { + Sub string `json:"sub"` + Name string `json:"name"` + Email string `json:"email"` +} + +func (p *GoogleProvider) GetName() string { + return "Google" +} + +func (p *GoogleProvider) IsEnabled() bool { + return system_setting.GetGoogleSettings().Enabled +} + +func (p *GoogleProvider) ExchangeToken(ctx context.Context, code string, c *gin.Context) (*OAuthToken, error) { + _ = c + if code == "" { + return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil) + } + + logger.LogDebug(ctx, "[OAuth-Google] ExchangeToken: code=%s...", code[:min(len(code), 10)]) + + settings := system_setting.GetGoogleSettings() + redirectUri := fmt.Sprintf("%s/oauth/google", 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-Google] ExchangeToken: redirect_uri=%s", redirectUri) + + req, err := http.NewRequestWithContext(ctx, "POST", "https://oauth2.googleapis.com/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-Google] ExchangeToken error: %s", err.Error())) + return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthConnectFailed, map[string]any{"Provider": "Google"}, err.Error()) + } + defer res.Body.Close() + + logger.LogDebug(ctx, "[OAuth-Google] ExchangeToken response status: %d", res.StatusCode) + + var googleResponse googleOAuthResponse + if err = common.DecodeJson(res.Body, &googleResponse); err != nil { + logger.LogError(ctx, fmt.Sprintf("[OAuth-Google] ExchangeToken decode error: %s", err.Error())) + return nil, err + } + + if googleResponse.AccessToken == "" { + logger.LogError(ctx, "[OAuth-Google] ExchangeToken failed: empty access token") + return nil, NewOAuthError(i18n.MsgOAuthTokenFailed, map[string]any{"Provider": "Google"}) + } + + return &OAuthToken{ + AccessToken: googleResponse.AccessToken, + TokenType: googleResponse.TokenType, + ExpiresIn: googleResponse.ExpiresIn, + Scope: googleResponse.Scope, + IDToken: googleResponse.IDToken, + }, nil +} + +func (p *GoogleProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*OAuthUser, error) { + req, err := http.NewRequestWithContext(ctx, "GET", "https://openidconnect.googleapis.com/v1/userinfo", 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-Google] GetUserInfo error: %s", err.Error())) + return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthConnectFailed, map[string]any{"Provider": "Google"}, err.Error()) + } + defer res.Body.Close() + + logger.LogDebug(ctx, "[OAuth-Google] GetUserInfo response status: %d", res.StatusCode) + + if res.StatusCode != http.StatusOK { + return nil, NewOAuthError(i18n.MsgOAuthGetUserErr, nil) + } + + var userInfo googleUser + if err = common.DecodeJson(res.Body, &userInfo); err != nil { + logger.LogError(ctx, fmt.Sprintf("[OAuth-Google] GetUserInfo decode error: %s", err.Error())) + return nil, err + } + + if userInfo.Sub == "" { + logger.LogError(ctx, "[OAuth-Google] GetUserInfo failed: empty sub") + return nil, NewOAuthError(i18n.MsgOAuthUserInfoEmpty, map[string]any{"Provider": "Google"}) + } + + return &OAuthUser{ + ProviderUserID: userInfo.Sub, + Username: userInfo.Email, + DisplayName: userInfo.Name, + Email: userInfo.Email, + }, nil +} + +func (p *GoogleProvider) IsUserIDTaken(providerUserID string) bool { + return model.IsGoogleIdAlreadyTaken(providerUserID) +} + +func (p *GoogleProvider) FillUserByProviderID(user *model.User, providerUserID string) error { + user.GoogleId = providerUserID + return user.FillUserByGoogleId() +} + +func (p *GoogleProvider) SetProviderUserID(user *model.User, providerUserID string) { + user.GoogleId = providerUserID +} + +func (p *GoogleProvider) GetProviderPrefix() string { + return "google_" +} diff --git a/setting/system_setting/google.go b/setting/system_setting/google.go new file mode 100644 index 000000000000..b118e5206fc3 --- /dev/null +++ b/setting/system_setting/google.go @@ -0,0 +1,19 @@ +package system_setting + +import "github.com/QuantumNous/new-api/setting/config" + +type GoogleSettings struct { + Enabled bool `json:"enabled"` + ClientId string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} + +var defaultGoogleSettings = GoogleSettings{} + +func init() { + config.GlobalConfig.Register("google", &defaultGoogleSettings) +} + +func GetGoogleSettings() *GoogleSettings { + return &defaultGoogleSettings +} diff --git a/web/src/components/auth/LoginForm.jsx b/web/src/components/auth/LoginForm.jsx index 7e8c0ce017f1..3e3e8c3df1d2 100644 --- a/web/src/components/auth/LoginForm.jsx +++ b/web/src/components/auth/LoginForm.jsx @@ -32,6 +32,7 @@ import { getOAuthProviderIcon, setUserData, onGitHubOAuthClicked, + onGoogleOAuthClicked, onDiscordOAuthClicked, onOIDCClicked, onLinuxDOOAuthClicked, @@ -65,7 +66,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'; +import { SiDiscord, SiGoogle } from 'react-icons/si'; const LoginForm = () => { let navigate = useNavigate(); @@ -92,6 +93,7 @@ const LoginForm = () => { const [showEmailLogin, setShowEmailLogin] = useState(false); const [wechatLoading, setWechatLoading] = useState(false); const [githubLoading, setGithubLoading] = useState(false); + const [googleLoading, setGoogleLoading] = useState(false); const [discordLoading, setDiscordLoading] = useState(false); const [oidcLoading, setOidcLoading] = useState(false); const [linuxdoLoading, setLinuxdoLoading] = useState(false); @@ -135,6 +137,7 @@ const LoginForm = () => { (status.custom_oauth_providers || []).length > 0; const hasOAuthLoginOptions = Boolean( status.github_oauth || + status.google_oauth || status.discord_oauth || status.oidc_enabled || status.wechat_login || @@ -337,6 +340,20 @@ const LoginForm = () => { } }; + // 包装的Google登录点击处理 + const handleGoogleClick = () => { + if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { + showInfo(t('请先阅读并同意用户协议和隐私政策')); + return; + } + setGoogleLoading(true); + try { + onGoogleOAuthClicked(status.google_client_id, { shouldLogout: true }); + } finally { + setTimeout(() => setGoogleLoading(false), 3000); + } + }; + // 包装的Discord登录点击处理 const handleDiscordClick = () => { if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) { @@ -548,6 +565,27 @@ const LoginForm = () => { )} + {status.google_oauth && ( + + )} + {status.discord_oauth && ( )} + {status.google_oauth && ( + + )} + {status.discord_oauth && ( + + + {t('用以支持通过 Google 进行登录注册')} + + + + + + + + + + + + diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index 88122a564cff..3b0446a2bc3d 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -304,6 +304,17 @@ export async function onGitHubOAuthClicked(github_client_id, options = {}) { ); } +export async function onGoogleOAuthClicked(google_client_id, options = {}) { + const state = await prepareOAuthState(options); + if (!state) return; + const redirect_uri = `${window.location.origin}/oauth/google`; + const response_type = 'code'; + const scope = 'openid profile email'; + redirectToOAuthUrl( + `https://accounts.google.com/o/oauth2/v2/auth?client_id=${google_client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`, + ); +} + export async function onLinuxDOOAuthClicked( linuxdo_client_id, options = { shouldLogout: false },