Update SproutGate

This commit is contained in:
2026-05-13 12:19:36 +08:00
parent f7db8aa053
commit a37b92e144
51 changed files with 12034 additions and 254 deletions

View File

@@ -0,0 +1,509 @@
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
}