feat: 增加 GitHub 和 Google 邮箱快捷登录

This commit is contained in:
lyen1688
2026-05-06 16:06:11 +08:00
parent a1106e8167
commit af550fa64e
35 changed files with 2656 additions and 74 deletions
@@ -0,0 +1,274 @@
package service
import (
"context"
"errors"
"fmt"
"net/mail"
"strings"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/authidentity"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
)
type EmailOAuthIdentityInput struct {
ProviderType string
ProviderKey string
ProviderSubject string
Email string
EmailVerified bool
Username string
DisplayName string
AvatarURL string
UpstreamMetadata map[string]any
}
func (s *AuthService) LoginOrRegisterVerifiedEmailOAuth(ctx context.Context, input EmailOAuthIdentityInput) (*TokenPair, *User, error) {
return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, "", "")
}
func (s *AuthService) LoginOrRegisterVerifiedEmailOAuthWithInvitation(
ctx context.Context,
input EmailOAuthIdentityInput,
invitationCode string,
affiliateCode string,
) (*TokenPair, *User, error) {
return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, invitationCode, affiliateCode)
}
func (s *AuthService) loginOrRegisterVerifiedEmailOAuth(
ctx context.Context,
input EmailOAuthIdentityInput,
invitationCode string,
affiliateCode string,
) (*TokenPair, *User, error) {
if s == nil || s.userRepo == nil || s.entClient == nil {
return nil, nil, ErrServiceUnavailable
}
providerType := normalizeOAuthSignupSource(input.ProviderType)
if providerType != "github" && providerType != "google" {
return nil, nil, infraerrors.BadRequest("OAUTH_PROVIDER_INVALID", "oauth provider is invalid")
}
providerKey := strings.TrimSpace(input.ProviderKey)
if providerKey == "" {
providerKey = providerType
}
providerSubject := strings.TrimSpace(input.ProviderSubject)
if providerSubject == "" {
return nil, nil, infraerrors.BadRequest("OAUTH_SUBJECT_MISSING", "oauth subject is missing")
}
if !input.EmailVerified {
return nil, nil, infraerrors.Forbidden("OAUTH_EMAIL_NOT_VERIFIED", "oauth email is not verified")
}
email := strings.TrimSpace(strings.ToLower(input.Email))
if email == "" || len(email) > 255 {
return nil, nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
}
if _, err := mail.ParseAddress(email); err != nil {
return nil, nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
}
if isReservedEmail(email) {
return nil, nil, ErrEmailReserved
}
if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
return nil, nil, err
}
identityUser, err := s.findEmailOAuthIdentityOwner(ctx, providerType, providerKey, providerSubject)
if err != nil {
return nil, nil, err
}
if identityUser != nil && !strings.EqualFold(strings.TrimSpace(identityUser.Email), email) {
return nil, nil, infraerrors.Conflict("AUTH_IDENTITY_EMAIL_MISMATCH", "oauth identity belongs to a different email")
}
user := identityUser
created := false
if user == nil {
user, err = s.userRepo.GetByEmail(ctx, email)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
user, err = s.createEmailOAuthUser(ctx, email, input.Username, providerType, invitationCode, affiliateCode)
if err != nil {
return nil, nil, err
}
created = true
} else {
logger.LegacyPrintf("service.auth", "[Auth] Database error during %s oauth login: %v", providerType, err)
return nil, nil, ErrServiceUnavailable
}
}
}
if !user.IsActive() {
return nil, nil, ErrUserNotActive
}
if err := s.ensureEmailOAuthIdentity(ctx, user.ID, EmailOAuthIdentityInput{
ProviderType: providerType,
ProviderKey: providerKey,
ProviderSubject: providerSubject,
Email: email,
EmailVerified: input.EmailVerified,
Username: input.Username,
DisplayName: input.DisplayName,
AvatarURL: input.AvatarURL,
UpstreamMetadata: input.UpstreamMetadata,
}); err != nil {
return nil, nil, err
}
if user.Username == "" && strings.TrimSpace(input.Username) != "" {
user.Username = strings.TrimSpace(input.Username)
if err := s.userRepo.Update(ctx, user); err != nil {
logger.LegacyPrintf("service.auth", "[Auth] Failed to update username after %s oauth login: %v", providerType, err)
}
}
if !created {
if err := s.ApplyProviderDefaultSettingsOnFirstBind(ctx, user.ID, providerType); err != nil {
logger.LegacyPrintf("service.auth", "[Auth] Failed to apply %s first bind defaults: %v", providerType, err)
}
}
s.RecordSuccessfulLogin(ctx, user.ID)
tokenPair, err := s.GenerateTokenPair(ctx, user, "")
if err != nil {
return nil, nil, fmt.Errorf("generate token pair: %w", err)
}
return tokenPair, user, nil
}
func (s *AuthService) createEmailOAuthUser(ctx context.Context, email, username, providerType, invitationCode, affiliateCode string) (*User, error) {
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
return nil, ErrRegDisabled
}
invitationRedeemCode, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode)
if err != nil {
if errors.Is(err, ErrInvitationCodeRequired) {
return nil, ErrOAuthInvitationRequired
}
return nil, err
}
randomPassword, err := randomHexString(32)
if err != nil {
return nil, ErrServiceUnavailable
}
hashedPassword, err := s.HashPassword(randomPassword)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
grantPlan := s.resolveSignupGrantPlan(ctx, providerType)
var defaultRPMLimit int
if s.settingService != nil {
defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx)
}
user := &User{
Email: email,
Username: strings.TrimSpace(username),
PasswordHash: hashedPassword,
Role: RoleUser,
Balance: grantPlan.Balance,
Concurrency: grantPlan.Concurrency,
RPMLimit: defaultRPMLimit,
Status: StatusActive,
SignupSource: providerType,
}
if err := s.userRepo.Create(ctx, user); err != nil {
if errors.Is(err, ErrEmailExists) {
existing, loadErr := s.userRepo.GetByEmail(ctx, email)
if loadErr != nil {
return nil, ErrServiceUnavailable
}
return existing, nil
}
return nil, ErrServiceUnavailable
}
s.postAuthUserBootstrap(ctx, user, providerType, false)
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
s.bindOAuthAffiliate(ctx, user.ID, affiliateCode)
if invitationRedeemCode != nil {
if err := s.useOAuthRegistrationInvitation(ctx, invitationRedeemCode.ID, user.ID); err != nil {
_ = s.RollbackOAuthEmailAccountCreation(ctx, user.ID, invitationCode)
return nil, ErrInvitationCodeInvalid
}
}
return user, nil
}
func (s *AuthService) findEmailOAuthIdentityOwner(ctx context.Context, providerType, providerKey, providerSubject string) (*User, error) {
identity, err := s.entClient.AuthIdentity.Query().
Where(
authidentity.ProviderTypeEQ(providerType),
authidentity.ProviderKeyEQ(providerKey),
authidentity.ProviderSubjectEQ(providerSubject),
).
Only(ctx)
if err != nil {
if dbent.IsNotFound(err) {
return nil, nil
}
return nil, infraerrors.InternalServer("AUTH_IDENTITY_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err)
}
user, err := s.userRepo.GetByID(ctx, identity.UserID)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return nil, nil
}
return nil, ErrServiceUnavailable
}
return user, nil
}
func (s *AuthService) ensureEmailOAuthIdentity(ctx context.Context, userID int64, input EmailOAuthIdentityInput) error {
metadata := map[string]any{
"email": strings.TrimSpace(strings.ToLower(input.Email)),
"email_verified": input.EmailVerified,
}
for key, value := range input.UpstreamMetadata {
metadata[key] = value
}
if strings.TrimSpace(input.Username) != "" {
metadata["username"] = strings.TrimSpace(input.Username)
}
if strings.TrimSpace(input.DisplayName) != "" {
metadata["display_name"] = strings.TrimSpace(input.DisplayName)
}
if strings.TrimSpace(input.AvatarURL) != "" {
metadata["avatar_url"] = strings.TrimSpace(input.AvatarURL)
}
providerType := normalizeOAuthSignupSource(input.ProviderType)
providerKey := strings.TrimSpace(input.ProviderKey)
providerSubject := strings.TrimSpace(input.ProviderSubject)
identity, err := s.entClient.AuthIdentity.Query().
Where(
authidentity.ProviderTypeEQ(providerType),
authidentity.ProviderKeyEQ(providerKey),
authidentity.ProviderSubjectEQ(providerSubject),
).
Only(ctx)
if err != nil && !dbent.IsNotFound(err) {
return infraerrors.InternalServer("AUTH_IDENTITY_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err)
}
if identity != nil {
if identity.UserID != userID {
return infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user")
}
_, err = s.entClient.AuthIdentity.UpdateOneID(identity.ID).
SetMetadata(metadata).
Save(ctx)
return err
}
_, err = s.entClient.AuthIdentity.Create().
SetUserID(userID).
SetProviderType(providerType).
SetProviderKey(providerKey).
SetProviderSubject(providerSubject).
SetMetadata(metadata).
Save(ctx)
return err
}
@@ -17,7 +17,7 @@ func normalizeOAuthSignupSource(signupSource string) string {
switch signupSource {
case "", "email":
return "email"
case "linuxdo", "wechat", "oidc":
case "linuxdo", "wechat", "oidc", "github", "google":
return signupSource
default:
return "email"
+4
View File
@@ -775,6 +775,10 @@ func authSourceSignupSettings(defaults *AuthSourceDefaultSettings, signupSource
return defaults.OIDC, true
case "wechat":
return defaults.WeChat, true
case "github":
return defaults.GitHub, true
case "google":
return defaults.Google, true
default:
return ProviderDefaultGrantSettings{}, false
}
@@ -173,6 +173,18 @@ const (
SettingKeyOIDCConnectUserInfoIDPath = "oidc_connect_userinfo_id_path"
SettingKeyOIDCConnectUserInfoUsernamePath = "oidc_connect_userinfo_username_path"
// GitHub / Google 邮箱快捷登录设置
SettingKeyGitHubOAuthEnabled = "github_oauth_enabled"
SettingKeyGitHubOAuthClientID = "github_oauth_client_id"
SettingKeyGitHubOAuthClientSecret = "github_oauth_client_secret"
SettingKeyGitHubOAuthRedirectURL = "github_oauth_redirect_url"
SettingKeyGitHubOAuthFrontendRedirectURL = "github_oauth_frontend_redirect_url"
SettingKeyGoogleOAuthEnabled = "google_oauth_enabled"
SettingKeyGoogleOAuthClientID = "google_oauth_client_id"
SettingKeyGoogleOAuthClientSecret = "google_oauth_client_secret"
SettingKeyGoogleOAuthRedirectURL = "google_oauth_redirect_url"
SettingKeyGoogleOAuthFrontendRedirectURL = "google_oauth_frontend_redirect_url"
// OEM设置
SettingKeySiteName = "site_name" // 网站名称
SettingKeySiteLogo = "site_logo" // 网站Logo (base64)
@@ -216,6 +228,16 @@ const (
SettingKeyAuthSourceDefaultWeChatSubscriptions = "auth_source_default_wechat_subscriptions"
SettingKeyAuthSourceDefaultWeChatGrantOnSignup = "auth_source_default_wechat_grant_on_signup"
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind = "auth_source_default_wechat_grant_on_first_bind"
SettingKeyAuthSourceDefaultGitHubBalance = "auth_source_default_github_balance"
SettingKeyAuthSourceDefaultGitHubConcurrency = "auth_source_default_github_concurrency"
SettingKeyAuthSourceDefaultGitHubSubscriptions = "auth_source_default_github_subscriptions"
SettingKeyAuthSourceDefaultGitHubGrantOnSignup = "auth_source_default_github_grant_on_signup"
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind = "auth_source_default_github_grant_on_first_bind"
SettingKeyAuthSourceDefaultGoogleBalance = "auth_source_default_google_balance"
SettingKeyAuthSourceDefaultGoogleConcurrency = "auth_source_default_google_concurrency"
SettingKeyAuthSourceDefaultGoogleSubscriptions = "auth_source_default_google_subscriptions"
SettingKeyAuthSourceDefaultGoogleGrantOnSignup = "auth_source_default_google_grant_on_signup"
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind = "auth_source_default_google_grant_on_first_bind"
SettingKeyForceEmailOnThirdPartySignup = "force_email_on_third_party_signup"
// 管理员 API Key
+267 -1
View File
@@ -129,6 +129,8 @@ type AuthSourceDefaultSettings struct {
LinuxDo ProviderDefaultGrantSettings
OIDC ProviderDefaultGrantSettings
WeChat ProviderDefaultGrantSettings
GitHub ProviderDefaultGrantSettings
Google ProviderDefaultGrantSettings
ForceEmailOnThirdPartySignup bool
}
@@ -169,6 +171,20 @@ var (
grantOnSignup: SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
grantOnFirstBind: SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
}
gitHubAuthSourceDefaultKeys = authSourceDefaultKeySet{
balance: SettingKeyAuthSourceDefaultGitHubBalance,
concurrency: SettingKeyAuthSourceDefaultGitHubConcurrency,
subscriptions: SettingKeyAuthSourceDefaultGitHubSubscriptions,
grantOnSignup: SettingKeyAuthSourceDefaultGitHubGrantOnSignup,
grantOnFirstBind: SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind,
}
googleAuthSourceDefaultKeys = authSourceDefaultKeySet{
balance: SettingKeyAuthSourceDefaultGoogleBalance,
concurrency: SettingKeyAuthSourceDefaultGoogleConcurrency,
subscriptions: SettingKeyAuthSourceDefaultGoogleSubscriptions,
grantOnSignup: SettingKeyAuthSourceDefaultGoogleGrantOnSignup,
grantOnFirstBind: SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind,
}
)
const (
@@ -177,6 +193,17 @@ const (
defaultWeChatConnectMode = "open"
defaultWeChatConnectScopes = "snsapi_login"
defaultWeChatConnectFrontend = "/auth/wechat/callback"
defaultGitHubOAuthAuthorize = "https://github.com/login/oauth/authorize"
defaultGitHubOAuthToken = "https://github.com/login/oauth/access_token"
defaultGitHubOAuthUserInfo = "https://api.github.com/user"
defaultGitHubOAuthEmails = "https://api.github.com/user/emails"
defaultGitHubOAuthScopes = "read:user user:email"
defaultGitHubOAuthFrontend = "/auth/oauth/callback"
defaultGoogleOAuthAuthorize = "https://accounts.google.com/o/oauth2/v2/auth"
defaultGoogleOAuthToken = "https://oauth2.googleapis.com/token"
defaultGoogleOAuthUserInfo = "https://openidconnect.googleapis.com/v1/userinfo"
defaultGoogleOAuthScopes = "openid email profile"
defaultGoogleOAuthFrontend = "/auth/oauth/callback"
)
func normalizeWeChatConnectModeSetting(raw string) string {
@@ -448,6 +475,12 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingPaymentEnabled,
SettingKeyOIDCConnectEnabled,
SettingKeyOIDCConnectProviderName,
SettingKeyGitHubOAuthEnabled,
SettingKeyGitHubOAuthClientID,
SettingKeyGitHubOAuthClientSecret,
SettingKeyGoogleOAuthEnabled,
SettingKeyGoogleOAuthClientID,
SettingKeyGoogleOAuthClientSecret,
SettingKeyBalanceLowNotifyEnabled,
SettingKeyBalanceLowNotifyThreshold,
SettingKeyBalanceLowNotifyRechargeURL,
@@ -482,6 +515,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
if oidcProviderName == "" {
oidcProviderName = "OIDC"
}
gitHubEnabled := s.emailOAuthPublicEnabled(settings, "github")
googleEnabled := s.emailOAuthPublicEnabled(settings, "google")
weChatEnabled, weChatOpenEnabled, weChatMPEnabled, weChatMobileEnabled := s.weChatOAuthCapabilitiesFromSettings(settings)
// Password reset requires email verification to be enabled
@@ -534,6 +569,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
PaymentEnabled: settings[SettingPaymentEnabled] == "true",
OIDCOAuthEnabled: oidcEnabled,
OIDCOAuthProviderName: oidcProviderName,
GitHubOAuthEnabled: gitHubEnabled,
GoogleOAuthEnabled: googleEnabled,
BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
@@ -677,6 +714,8 @@ type PublicSettingsInjectionPayload struct {
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"`
PaymentEnabled bool `json:"payment_enabled"`
Version string `json:"version"`
@@ -733,6 +772,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
BackendModeEnabled: settings.BackendModeEnabled,
PaymentEnabled: settings.PaymentEnabled,
Version: s.version,
@@ -806,6 +847,98 @@ func (s *SettingService) weChatOAuthCapabilitiesFromSettings(settings map[string
return openReady || mpReady, openReady, mpReady, mobileReady
}
func (s *SettingService) emailOAuthBaseConfig(provider string) config.EmailOAuthProviderConfig {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "github":
cfg := config.EmailOAuthProviderConfig{
AuthorizeURL: defaultGitHubOAuthAuthorize,
TokenURL: defaultGitHubOAuthToken,
UserInfoURL: defaultGitHubOAuthUserInfo,
EmailsURL: defaultGitHubOAuthEmails,
Scopes: defaultGitHubOAuthScopes,
FrontendRedirectURL: defaultGitHubOAuthFrontend,
}
if s != nil && s.cfg != nil {
cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GitHubOAuth)
}
return cfg
case "google":
cfg := config.EmailOAuthProviderConfig{
AuthorizeURL: defaultGoogleOAuthAuthorize,
TokenURL: defaultGoogleOAuthToken,
UserInfoURL: defaultGoogleOAuthUserInfo,
Scopes: defaultGoogleOAuthScopes,
FrontendRedirectURL: defaultGoogleOAuthFrontend,
}
if s != nil && s.cfg != nil {
cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GoogleOAuth)
}
return cfg
default:
return config.EmailOAuthProviderConfig{}
}
}
func mergeEmailOAuthBaseConfig(base, override config.EmailOAuthProviderConfig) config.EmailOAuthProviderConfig {
base.Enabled = override.Enabled
if strings.TrimSpace(override.ClientID) != "" {
base.ClientID = strings.TrimSpace(override.ClientID)
}
if strings.TrimSpace(override.ClientSecret) != "" {
base.ClientSecret = strings.TrimSpace(override.ClientSecret)
}
if strings.TrimSpace(override.AuthorizeURL) != "" {
base.AuthorizeURL = strings.TrimSpace(override.AuthorizeURL)
}
if strings.TrimSpace(override.TokenURL) != "" {
base.TokenURL = strings.TrimSpace(override.TokenURL)
}
if strings.TrimSpace(override.UserInfoURL) != "" {
base.UserInfoURL = strings.TrimSpace(override.UserInfoURL)
}
if strings.TrimSpace(override.EmailsURL) != "" {
base.EmailsURL = strings.TrimSpace(override.EmailsURL)
}
if strings.TrimSpace(override.Scopes) != "" {
base.Scopes = strings.TrimSpace(override.Scopes)
}
if strings.TrimSpace(override.RedirectURL) != "" {
base.RedirectURL = strings.TrimSpace(override.RedirectURL)
}
if strings.TrimSpace(override.FrontendRedirectURL) != "" {
base.FrontendRedirectURL = strings.TrimSpace(override.FrontendRedirectURL)
}
return base
}
func (s *SettingService) emailOAuthPublicEnabled(settings map[string]string, provider string) bool {
cfg := s.effectiveEmailOAuthConfig(settings, provider)
return cfg.Enabled && strings.TrimSpace(cfg.ClientID) != "" && strings.TrimSpace(cfg.ClientSecret) != ""
}
func (s *SettingService) effectiveEmailOAuthConfig(settings map[string]string, provider string) config.EmailOAuthProviderConfig {
cfg := s.emailOAuthBaseConfig(provider)
switch strings.ToLower(strings.TrimSpace(provider)) {
case "github":
if raw, ok := settings[SettingKeyGitHubOAuthEnabled]; ok {
cfg.Enabled = raw == "true"
}
cfg.ClientID = firstNonEmpty(settings[SettingKeyGitHubOAuthClientID], cfg.ClientID)
cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGitHubOAuthClientSecret], cfg.ClientSecret)
cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthRedirectURL], cfg.RedirectURL)
cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGitHubOAuthFrontend)
case "google":
if raw, ok := settings[SettingKeyGoogleOAuthEnabled]; ok {
cfg.Enabled = raw == "true"
}
cfg.ClientID = firstNonEmpty(settings[SettingKeyGoogleOAuthClientID], cfg.ClientID)
cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGoogleOAuthClientSecret], cfg.ClientSecret)
cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthRedirectURL], cfg.RedirectURL)
cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGoogleOAuthFrontend)
}
return cfg
}
// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON
// array string, returning only items with visibility != "admin".
func filterUserVisibleMenuItems(raw string) json.RawMessage {
@@ -1052,6 +1185,16 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
if settings.WeChatConnectFrontendRedirectURL == "" {
settings.WeChatConnectFrontendRedirectURL = defaultWeChatConnectFrontend
}
settings.GitHubOAuthRedirectURL = strings.TrimSpace(settings.GitHubOAuthRedirectURL)
settings.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(settings.GitHubOAuthFrontendRedirectURL)
if settings.GitHubOAuthFrontendRedirectURL == "" {
settings.GitHubOAuthFrontendRedirectURL = defaultGitHubOAuthFrontend
}
settings.GoogleOAuthRedirectURL = strings.TrimSpace(settings.GoogleOAuthRedirectURL)
settings.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(settings.GoogleOAuthFrontendRedirectURL)
if settings.GoogleOAuthFrontendRedirectURL == "" {
settings.GoogleOAuthFrontendRedirectURL = defaultGoogleOAuthFrontend
}
updates := make(map[string]string)
@@ -1121,6 +1264,22 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret
}
// GitHub / Google 邮箱快捷登录
updates[SettingKeyGitHubOAuthEnabled] = strconv.FormatBool(settings.GitHubOAuthEnabled)
updates[SettingKeyGitHubOAuthClientID] = strings.TrimSpace(settings.GitHubOAuthClientID)
updates[SettingKeyGitHubOAuthRedirectURL] = settings.GitHubOAuthRedirectURL
updates[SettingKeyGitHubOAuthFrontendRedirectURL] = settings.GitHubOAuthFrontendRedirectURL
if settings.GitHubOAuthClientSecret != "" {
updates[SettingKeyGitHubOAuthClientSecret] = strings.TrimSpace(settings.GitHubOAuthClientSecret)
}
updates[SettingKeyGoogleOAuthEnabled] = strconv.FormatBool(settings.GoogleOAuthEnabled)
updates[SettingKeyGoogleOAuthClientID] = strings.TrimSpace(settings.GoogleOAuthClientID)
updates[SettingKeyGoogleOAuthRedirectURL] = settings.GoogleOAuthRedirectURL
updates[SettingKeyGoogleOAuthFrontendRedirectURL] = settings.GoogleOAuthFrontendRedirectURL
if settings.GoogleOAuthClientSecret != "" {
updates[SettingKeyGoogleOAuthClientSecret] = strings.TrimSpace(settings.GoogleOAuthClientSecret)
}
// WeChat Connect OAuth 登录
updates[SettingKeyWeChatConnectEnabled] = strconv.FormatBool(settings.WeChatConnectEnabled)
updates[SettingKeyWeChatConnectAppID] = settings.WeChatConnectAppID
@@ -1273,17 +1432,21 @@ func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, sett
settings.LinuxDo.Subscriptions,
settings.OIDC.Subscriptions,
settings.WeChat.Subscriptions,
settings.GitHub.Subscriptions,
settings.Google.Subscriptions,
} {
if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil {
return nil, err
}
}
updates := make(map[string]string, 21)
updates := make(map[string]string, 31)
writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email)
writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo)
writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC)
writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat)
writeProviderDefaultGrantUpdates(updates, gitHubAuthSourceDefaultKeys, settings.GitHub)
writeProviderDefaultGrantUpdates(updates, googleAuthSourceDefaultKeys, settings.Google)
updates[SettingKeyForceEmailOnThirdPartySignup] = strconv.FormatBool(settings.ForceEmailOnThirdPartySignup)
return updates, nil
}
@@ -1362,6 +1525,61 @@ func (s *SettingService) validateDefaultSubscriptionGroups(ctx context.Context,
return nil
}
func (s *SettingService) GetEmailOAuthProviderConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider != "github" && provider != "google" {
return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_PROVIDER_NOT_FOUND", "oauth provider not found")
}
keys := []string{
SettingKeyGitHubOAuthEnabled,
SettingKeyGitHubOAuthClientID,
SettingKeyGitHubOAuthClientSecret,
SettingKeyGitHubOAuthRedirectURL,
SettingKeyGitHubOAuthFrontendRedirectURL,
SettingKeyGoogleOAuthEnabled,
SettingKeyGoogleOAuthClientID,
SettingKeyGoogleOAuthClientSecret,
SettingKeyGoogleOAuthRedirectURL,
SettingKeyGoogleOAuthFrontendRedirectURL,
}
settings, err := s.settingRepo.GetMultiple(ctx, keys)
if err != nil {
return config.EmailOAuthProviderConfig{}, fmt.Errorf("get email oauth settings: %w", err)
}
cfg := s.effectiveEmailOAuthConfig(settings, provider)
if !cfg.Enabled {
return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
}
if strings.TrimSpace(cfg.ClientID) == "" {
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
}
if strings.TrimSpace(cfg.ClientSecret) == "" {
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
}
for label, rawURL := range map[string]string{
"authorize": cfg.AuthorizeURL,
"token": cfg.TokenURL,
"userinfo": cfg.UserInfoURL,
"redirect": cfg.RedirectURL,
} {
if strings.TrimSpace(rawURL) == "" {
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url not configured")
}
if err := config.ValidateAbsoluteHTTPURL(rawURL); err != nil {
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url invalid")
}
}
if strings.TrimSpace(cfg.EmailsURL) != "" {
if err := config.ValidateAbsoluteHTTPURL(cfg.EmailsURL); err != nil {
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth emails url invalid")
}
}
if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil {
return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
}
return cfg, nil
}
// IsRegistrationEnabled 检查是否开放注册
func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
@@ -1711,6 +1929,16 @@ func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*Aut
SettingKeyAuthSourceDefaultWeChatSubscriptions,
SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
SettingKeyAuthSourceDefaultGitHubBalance,
SettingKeyAuthSourceDefaultGitHubConcurrency,
SettingKeyAuthSourceDefaultGitHubSubscriptions,
SettingKeyAuthSourceDefaultGitHubGrantOnSignup,
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind,
SettingKeyAuthSourceDefaultGoogleBalance,
SettingKeyAuthSourceDefaultGoogleConcurrency,
SettingKeyAuthSourceDefaultGoogleSubscriptions,
SettingKeyAuthSourceDefaultGoogleGrantOnSignup,
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind,
SettingKeyForceEmailOnThirdPartySignup,
}
@@ -1724,6 +1952,8 @@ func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*Aut
LinuxDo: parseProviderDefaultGrantSettings(settings, linuxDoAuthSourceDefaultKeys),
OIDC: parseProviderDefaultGrantSettings(settings, oidcAuthSourceDefaultKeys),
WeChat: parseProviderDefaultGrantSettings(settings, weChatAuthSourceDefaultKeys),
GitHub: parseProviderDefaultGrantSettings(settings, gitHubAuthSourceDefaultKeys),
Google: parseProviderDefaultGrantSettings(settings, googleAuthSourceDefaultKeys),
ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
}, nil
}
@@ -1824,6 +2054,16 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyWeChatConnectScopes: "snsapi_login",
SettingKeyWeChatConnectRedirectURL: "",
SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend,
SettingKeyGitHubOAuthEnabled: "false",
SettingKeyGitHubOAuthClientID: "",
SettingKeyGitHubOAuthClientSecret: "",
SettingKeyGitHubOAuthRedirectURL: "",
SettingKeyGitHubOAuthFrontendRedirectURL: defaultGitHubOAuthFrontend,
SettingKeyGoogleOAuthEnabled: "false",
SettingKeyGoogleOAuthClientID: "",
SettingKeyGoogleOAuthClientSecret: "",
SettingKeyGoogleOAuthRedirectURL: "",
SettingKeyGoogleOAuthFrontendRedirectURL: defaultGoogleOAuthFrontend,
SettingKeyOIDCConnectEnabled: "false",
SettingKeyOIDCConnectProviderName: "OIDC",
SettingKeyOIDCConnectClientID: "",
@@ -1874,6 +2114,16 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]",
SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "false",
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultGitHubBalance: "0",
SettingKeyAuthSourceDefaultGitHubConcurrency: "5",
SettingKeyAuthSourceDefaultGitHubSubscriptions: "[]",
SettingKeyAuthSourceDefaultGitHubGrantOnSignup: "false",
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultGoogleBalance: "0",
SettingKeyAuthSourceDefaultGoogleConcurrency: "5",
SettingKeyAuthSourceDefaultGoogleSubscriptions: "[]",
SettingKeyAuthSourceDefaultGoogleGrantOnSignup: "false",
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind: "false",
SettingKeyForceEmailOnThirdPartySignup: "false",
SettingKeySMTPPort: "587",
SettingKeySMTPUseTLS: "false",
@@ -2173,6 +2423,22 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
}
result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != ""
gitHubEffective := s.effectiveEmailOAuthConfig(settings, "github")
result.GitHubOAuthEnabled = gitHubEffective.Enabled
result.GitHubOAuthClientID = strings.TrimSpace(gitHubEffective.ClientID)
result.GitHubOAuthClientSecret = strings.TrimSpace(gitHubEffective.ClientSecret)
result.GitHubOAuthClientSecretConfigured = result.GitHubOAuthClientSecret != ""
result.GitHubOAuthRedirectURL = strings.TrimSpace(gitHubEffective.RedirectURL)
result.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(gitHubEffective.FrontendRedirectURL)
googleEffective := s.effectiveEmailOAuthConfig(settings, "google")
result.GoogleOAuthEnabled = googleEffective.Enabled
result.GoogleOAuthClientID = strings.TrimSpace(googleEffective.ClientID)
result.GoogleOAuthClientSecret = strings.TrimSpace(googleEffective.ClientSecret)
result.GoogleOAuthClientSecretConfigured = result.GoogleOAuthClientSecret != ""
result.GoogleOAuthRedirectURL = strings.TrimSpace(googleEffective.RedirectURL)
result.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(googleEffective.FrontendRedirectURL)
// WeChat Connect 设置:
// - 优先读取 DB 系统设置
// - 缺失时回退到 config/env,保持升级兼容
+16
View File
@@ -89,6 +89,20 @@ type SystemSettings struct {
OIDCConnectUserInfoIDPath string
OIDCConnectUserInfoUsernamePath string
// GitHub / Google 邮箱快捷登录
GitHubOAuthEnabled bool
GitHubOAuthClientID string
GitHubOAuthClientSecret string
GitHubOAuthClientSecretConfigured bool
GitHubOAuthRedirectURL string
GitHubOAuthFrontendRedirectURL string
GoogleOAuthEnabled bool
GoogleOAuthClientID string
GoogleOAuthClientSecret string
GoogleOAuthClientSecretConfigured bool
GoogleOAuthRedirectURL string
GoogleOAuthFrontendRedirectURL string
SiteName string
SiteLogo string
SiteSubtitle string
@@ -217,6 +231,8 @@ type PublicSettings struct {
PaymentEnabled bool
OIDCOAuthEnabled bool
OIDCOAuthProviderName string
GitHubOAuthEnabled bool
GoogleOAuthEnabled bool
Version string
BalanceLowNotifyEnabled bool