package storage import ( "errors" "net/url" "strings" "gorm.io/gorm" "sproutgate-backend/internal/models" ) const defaultGiteaBaseURL = "https://git.shumengya.top" const defaultLinuxdoConnectBaseURL = "https://connect.linux.do" const defaultMicrosoftTenant = "common" func (s *Store) loadOrCreateOAuthConfig() error { var cfg OAuthConfig found, err := s.getConfig("oauth", &cfg) if err != nil { return err } changed := !found if strings.TrimSpace(cfg.GiteaBaseURL) == "" { cfg.GiteaBaseURL = defaultGiteaBaseURL changed = true } cfg.GiteaBaseURL = strings.TrimRight(strings.TrimSpace(cfg.GiteaBaseURL), "/") if strings.TrimSpace(cfg.LinuxdoConnectBaseURL) == "" { cfg.LinuxdoConnectBaseURL = defaultLinuxdoConnectBaseURL changed = true } cfg.LinuxdoConnectBaseURL = strings.TrimRight(strings.TrimSpace(cfg.LinuxdoConnectBaseURL), "/") if strings.TrimSpace(cfg.MicrosoftTenant) == "" { cfg.MicrosoftTenant = defaultMicrosoftTenant changed = true } else { cfg.MicrosoftTenant = strings.TrimSpace(cfg.MicrosoftTenant) } if len(cfg.AllowedReturnPrefixes) == 0 { cfg.AllowedReturnPrefixes = []string{ "http://localhost:5173/", "http://127.0.0.1:5173/", } changed = true } if changed { if err := s.setConfig("oauth", cfg); err != nil { return err } } s.mu.Lock() s.oauthConfig = cfg s.mu.Unlock() return nil } // GetOAuthConfig 返回当前 OAuth 设置副本(含密钥;仅服务端使用)。 func (s *Store) GetOAuthConfig() OAuthConfig { s.mu.RLock() defer s.mu.RUnlock() return s.oauthConfig } // MergeUpdateOAuthConfig 更新 OAuth 设置;空字符串的 ClientSecret 表示保留原值。 func (s *Store) MergeUpdateOAuthConfig(in OAuthConfig) error { s.mu.Lock() defer s.mu.Unlock() prev := s.oauthConfig if strings.TrimSpace(in.GitHubClientSecret) == "" { in.GitHubClientSecret = prev.GitHubClientSecret } if strings.TrimSpace(in.GiteaClientSecret) == "" { in.GiteaClientSecret = prev.GiteaClientSecret } if strings.TrimSpace(in.LinuxdoClientSecret) == "" { in.LinuxdoClientSecret = prev.LinuxdoClientSecret } if strings.TrimSpace(in.GoogleClientSecret) == "" { in.GoogleClientSecret = prev.GoogleClientSecret } if strings.TrimSpace(in.MicrosoftClientSecret) == "" { in.MicrosoftClientSecret = prev.MicrosoftClientSecret } if strings.TrimSpace(in.GiteaBaseURL) == "" { in.GiteaBaseURL = defaultGiteaBaseURL } in.GiteaBaseURL = strings.TrimRight(strings.TrimSpace(in.GiteaBaseURL), "/") if strings.TrimSpace(in.LinuxdoConnectBaseURL) == "" { in.LinuxdoConnectBaseURL = defaultLinuxdoConnectBaseURL } in.LinuxdoConnectBaseURL = strings.TrimRight(strings.TrimSpace(in.LinuxdoConnectBaseURL), "/") if in.AllowedReturnPrefixes == nil || len(in.AllowedReturnPrefixes) == 0 { in.AllowedReturnPrefixes = prev.AllowedReturnPrefixes } if strings.TrimSpace(in.MicrosoftTenant) == "" { if strings.TrimSpace(prev.MicrosoftTenant) != "" { in.MicrosoftTenant = prev.MicrosoftTenant } else { in.MicrosoftTenant = defaultMicrosoftTenant } } else { in.MicrosoftTenant = strings.TrimSpace(in.MicrosoftTenant) } if err := s.setConfig("oauth", in); err != nil { return err } s.oauthConfig = in return nil } // PublicOAuthFlags 可对外暴露的开关(无密钥)。 type PublicOAuthFlags struct { GitHubEnabled bool `json:"githubEnabled"` GiteaEnabled bool `json:"giteaEnabled"` GoogleEnabled bool `json:"googleEnabled"` MicrosoftEnabled bool `json:"microsoftEnabled"` LinuxdoEnabled bool `json:"linuxdoEnabled"` } // PublicOAuthFlags 返回是否启用各提供商。 func (s *Store) PublicOAuthFlags() PublicOAuthFlags { s.mu.RLock() defer s.mu.RUnlock() return PublicOAuthFlags{ GitHubEnabled: s.oauthConfig.GitHubEnabled, GiteaEnabled: s.oauthConfig.GiteaEnabled, GoogleEnabled: s.oauthConfig.GoogleEnabled, MicrosoftEnabled: s.oauthConfig.MicrosoftEnabled, LinuxdoEnabled: s.oauthConfig.LinuxdoEnabled, } } // OAuthSignUpAllowed 是否允许用 OAuth 创建新用户(结合注册策略与后台开关)。 func (s *Store) OAuthSignUpAllowed() bool { s.mu.RLock() defer s.mu.RUnlock() if s.oauthConfig.AllowOAuthSignUpWhenInviteRequired { return true } if s.registrationConfig.RequireInviteCode { return false } return true } // GetUserByEmail 主邮箱精确匹配(大小写不敏感,存库一般为小写)。 func (s *Store) GetUserByEmail(email string) (models.UserRecord, bool, error) { email = strings.TrimSpace(email) if email == "" { return models.UserRecord{}, false, nil } var row DBUser result := s.db.First(&row, "LOWER(email) = LOWER(?)", email) if errors.Is(result.Error, gorm.ErrRecordNotFound) { return models.UserRecord{}, false, nil } if result.Error != nil { return models.UserRecord{}, false, result.Error } return row.toRecord(), true, nil } // GetUserByGitHubID 非空时查询。 func (s *Store) GetUserByGitHubID(id string) (models.UserRecord, bool, error) { id = strings.TrimSpace(id) if id == "" { return models.UserRecord{}, false, nil } var row DBUser result := s.db.First(&row, "github_user_id = ?", id) if errors.Is(result.Error, gorm.ErrRecordNotFound) { return models.UserRecord{}, false, nil } if result.Error != nil { return models.UserRecord{}, false, result.Error } return row.toRecord(), true, nil } // GetUserByGiteaID 非空时查询。 func (s *Store) GetUserByGiteaID(id string) (models.UserRecord, bool, error) { id = strings.TrimSpace(id) if id == "" { return models.UserRecord{}, false, nil } var row DBUser result := s.db.First(&row, "gitea_user_id = ?", id) if errors.Is(result.Error, gorm.ErrRecordNotFound) { return models.UserRecord{}, false, nil } if result.Error != nil { return models.UserRecord{}, false, result.Error } return row.toRecord(), true, nil } // SetUserGitHubID 将 GitHub 用户 ID 绑定到账户;id 被他人占用时返回错误。 func (s *Store) SetUserGitHubID(account, githubID string) error { account = strings.TrimSpace(account) githubID = strings.TrimSpace(githubID) if account == "" || githubID == "" { return errors.New("account and id required") } other, found, err := s.GetUserByGitHubID(githubID) if err != nil { return err } if found && !strings.EqualFold(other.Account, account) { return errors.New("github account already linked to another user") } u, found, err := s.GetUser(account) if err != nil || !found { return errors.New("user not found") } if strings.TrimSpace(u.GitHubUserID) != "" && u.GitHubUserID != githubID { return errors.New("user already has a different GitHub link") } u.GitHubUserID = githubID return s.SaveUser(u) } // SetUserGiteaID 将 Gitea 用户 ID 绑定到账户。 func (s *Store) SetUserGiteaID(account, giteaID string) error { account = strings.TrimSpace(account) giteaID = strings.TrimSpace(giteaID) if account == "" || giteaID == "" { return errors.New("account and id required") } other, found, err := s.GetUserByGiteaID(giteaID) if err != nil { return err } if found && !strings.EqualFold(other.Account, account) { return errors.New("gitea account already linked to another user") } u, found, err := s.GetUser(account) if err != nil || !found { return errors.New("user not found") } if strings.TrimSpace(u.GiteaUserID) != "" && u.GiteaUserID != giteaID { return errors.New("user already has a different Gitea link") } u.GiteaUserID = giteaID return s.SaveUser(u) } // ClearUserGitHubID 解绑 GitHub。 func (s *Store) ClearUserGitHubID(account string) error { u, found, err := s.GetUser(strings.TrimSpace(account)) if err != nil { return err } if !found { return errors.New("user not found") } u.GitHubUserID = "" return s.SaveUser(u) } // ClearUserGiteaID 解绑 Gitea。 func (s *Store) ClearUserGiteaID(account string) error { u, found, err := s.GetUser(strings.TrimSpace(account)) if err != nil { return err } if !found { return errors.New("user not found") } u.GiteaUserID = "" return s.SaveUser(u) } // GetUserByLinuxdoID 非空时查询(LINUX DO Connect 用户主键)。 func (s *Store) GetUserByLinuxdoID(id string) (models.UserRecord, bool, error) { id = strings.TrimSpace(id) if id == "" { return models.UserRecord{}, false, nil } var row DBUser result := s.db.First(&row, "linuxdo_user_id = ?", id) if errors.Is(result.Error, gorm.ErrRecordNotFound) { return models.UserRecord{}, false, nil } if result.Error != nil { return models.UserRecord{}, false, result.Error } return row.toRecord(), true, nil } // SetUserLinuxdoID 绑定 LINUX DO 用户 ID。 func (s *Store) SetUserLinuxdoID(account, linuxdoID string) error { account = strings.TrimSpace(account) linuxdoID = strings.TrimSpace(linuxdoID) if account == "" || linuxdoID == "" { return errors.New("account and id required") } other, found, err := s.GetUserByLinuxdoID(linuxdoID) if err != nil { return err } if found && !strings.EqualFold(other.Account, account) { return errors.New("linux.do account already linked to another user") } u, found, err := s.GetUser(account) if err != nil || !found { return errors.New("user not found") } if strings.TrimSpace(u.LinuxdoUserID) != "" && u.LinuxdoUserID != linuxdoID { return errors.New("user already has a different LINUX DO link") } u.LinuxdoUserID = linuxdoID return s.SaveUser(u) } // ClearUserLinuxdoID 解绑 LINUX DO。 func (s *Store) ClearUserLinuxdoID(account string) error { u, found, err := s.GetUser(strings.TrimSpace(account)) if err != nil { return err } if !found { return errors.New("user not found") } u.LinuxdoUserID = "" return s.SaveUser(u) } // GetUserByGoogleID 非空时查询。 func (s *Store) GetUserByGoogleID(id string) (models.UserRecord, bool, error) { id = strings.TrimSpace(id) if id == "" { return models.UserRecord{}, false, nil } var row DBUser result := s.db.First(&row, "google_user_id = ?", id) if errors.Is(result.Error, gorm.ErrRecordNotFound) { return models.UserRecord{}, false, nil } if result.Error != nil { return models.UserRecord{}, false, result.Error } return row.toRecord(), true, nil } // SetUserGoogleID 将 Google 用户 ID 绑定到账户。 func (s *Store) SetUserGoogleID(account, googleID string) error { account = strings.TrimSpace(account) googleID = strings.TrimSpace(googleID) if account == "" || googleID == "" { return errors.New("account and id required") } other, found, err := s.GetUserByGoogleID(googleID) if err != nil { return err } if found && !strings.EqualFold(other.Account, account) { return errors.New("google account already linked to another user") } u, found, err := s.GetUser(account) if err != nil || !found { return errors.New("user not found") } if strings.TrimSpace(u.GoogleUserID) != "" && u.GoogleUserID != googleID { return errors.New("user already has a different Google link") } u.GoogleUserID = googleID return s.SaveUser(u) } // ClearUserGoogleID 解绑 Google。 func (s *Store) ClearUserGoogleID(account string) error { u, found, err := s.GetUser(strings.TrimSpace(account)) if err != nil { return err } if !found { return errors.New("user not found") } u.GoogleUserID = "" return s.SaveUser(u) } // GetUserByMicrosoftID 非空时查询。 func (s *Store) GetUserByMicrosoftID(id string) (models.UserRecord, bool, error) { id = strings.TrimSpace(id) if id == "" { return models.UserRecord{}, false, nil } var row DBUser result := s.db.First(&row, "microsoft_user_id = ?", id) if errors.Is(result.Error, gorm.ErrRecordNotFound) { return models.UserRecord{}, false, nil } if result.Error != nil { return models.UserRecord{}, false, result.Error } return row.toRecord(), true, nil } // SetUserMicrosoftID 将 Microsoft 用户 ID 绑定到账户。 func (s *Store) SetUserMicrosoftID(account, msID string) error { account = strings.TrimSpace(account) msID = strings.TrimSpace(msID) if account == "" || msID == "" { return errors.New("account and id required") } other, found, err := s.GetUserByMicrosoftID(msID) if err != nil { return err } if found && !strings.EqualFold(other.Account, account) { return errors.New("microsoft account already linked to another user") } u, found, err := s.GetUser(account) if err != nil || !found { return errors.New("user not found") } if strings.TrimSpace(u.MicrosoftUserID) != "" && u.MicrosoftUserID != msID { return errors.New("user already has a different Microsoft link") } u.MicrosoftUserID = msID return s.SaveUser(u) } // ClearUserMicrosoftID 解绑 Microsoft。 func (s *Store) ClearUserMicrosoftID(account string) error { u, found, err := s.GetUser(strings.TrimSpace(account)) if err != nil { return err } if !found { return errors.New("user not found") } u.MicrosoftUserID = "" return s.SaveUser(u) } // ProposeUniqueOAuthAccount 生成未占用的随机自助格式账户名,用于 OAuth 注册。 func (s *Store) ProposeUniqueOAuthAccount() (string, error) { for i := 0; i < 48; i++ { raw, err := randomOAuthLocalPart(12) if err != nil { return "", err } acc := "oauth" + raw if err := s.ValidateSelfServiceAccount(acc); err != nil { continue } _, found, err := s.GetUser(acc) if err != nil { return "", err } if !found { return acc, nil } } return "", errors.New("failed to allocate unique account") } // randomOAuthLocalPart 小写字母与数字,长度 n。 func randomOAuthLocalPart(n int) (string, error) { if n < 1 { n = 8 } const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" buf := make([]byte, n) randBytes, err := generateSecret() if err != nil { return "", err } for i := 0; i < n; i++ { buf[i] = alphabet[int(randBytes[i%len(randBytes)])%len(alphabet)] } return string(buf), nil } // IsReturnURLAllowed 校验回跳地址是否以白名单中某一前缀开头(需为 http(s) 绝对地址)。 func (c OAuthConfig) IsReturnURLAllowed(raw string) bool { raw = strings.TrimSpace(raw) if raw == "" { return false } u, err := url.Parse(raw) if err != nil || u.Scheme == "" || u.Host == "" { return false } if u.Scheme != "https" && u.Scheme != "http" { return false } norm := u.String() for _, pfx := range c.AllowedReturnPrefixes { pfx = strings.TrimSpace(pfx) if pfx == "" { continue } if !strings.HasSuffix(pfx, "/") { pfx += "/" } if strings.HasPrefix(norm, pfx) { return true } base := strings.TrimRight(pfx, "/") if norm == base || strings.HasPrefix(norm, base+"/") { return true } } return false }