Update SproutGate
This commit is contained in:
72
sproutgate-backend/internal/storage/config_hot_reload.go
Normal file
72
sproutgate-backend/internal/storage/config_hot_reload.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const configHotReloadMinInterval = 2 * time.Second
|
||||
|
||||
// MaybeSyncRuntimeConfigFromDB 在「管理后台在其它节点保存了配置」时,用 MySQL 中的最新行覆盖本进程内存缓存,避免多副本部署必须重启才生效。
|
||||
// 带最短间隔节流,减少读库频率。
|
||||
func (s *Store) MaybeSyncRuntimeConfigFromDB() {
|
||||
s.hotReloadMu.Lock()
|
||||
elapsed := time.Since(s.lastHotReload)
|
||||
should := s.lastHotReload.IsZero() || elapsed >= configHotReloadMinInterval
|
||||
if should {
|
||||
s.lastHotReload = time.Now()
|
||||
}
|
||||
s.hotReloadMu.Unlock()
|
||||
if !should {
|
||||
return
|
||||
}
|
||||
_ = s.syncRuntimeConfigFromDB()
|
||||
}
|
||||
|
||||
func (s *Store) syncRuntimeConfigFromDB() error {
|
||||
if err := s.reloadOAuthConfigFromDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.reloadTurnstileConfigFromDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
// 含邀请码列表等
|
||||
return s.loadOrCreateRegistrationConfig()
|
||||
}
|
||||
|
||||
func (s *Store) reloadOAuthConfigFromDB() error {
|
||||
var cfg OAuthConfig
|
||||
found, err := s.getConfig("oauth", &cfg)
|
||||
if err != nil || !found {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.GiteaBaseURL) == "" {
|
||||
cfg.GiteaBaseURL = defaultGiteaBaseURL
|
||||
}
|
||||
cfg.GiteaBaseURL = strings.TrimRight(strings.TrimSpace(cfg.GiteaBaseURL), "/")
|
||||
if strings.TrimSpace(cfg.LinuxdoConnectBaseURL) == "" {
|
||||
cfg.LinuxdoConnectBaseURL = defaultLinuxdoConnectBaseURL
|
||||
}
|
||||
cfg.LinuxdoConnectBaseURL = strings.TrimRight(strings.TrimSpace(cfg.LinuxdoConnectBaseURL), "/")
|
||||
if strings.TrimSpace(cfg.MicrosoftTenant) == "" {
|
||||
cfg.MicrosoftTenant = defaultMicrosoftTenant
|
||||
} else {
|
||||
cfg.MicrosoftTenant = strings.TrimSpace(cfg.MicrosoftTenant)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.oauthConfig = cfg
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) reloadTurnstileConfigFromDB() error {
|
||||
var cfg TurnstileConfig
|
||||
_, err := s.getConfig("turnstile", &cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.turnstileConfig = cfg
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
@@ -104,10 +105,30 @@ type DBUser struct {
|
||||
BannedAt string `gorm:"column:banned_at;size:50"`
|
||||
TokenEpoch int64 `gorm:"column:token_epoch;default:0"`
|
||||
AuthClients AuthClientSlice `gorm:"column:auth_clients;type:mediumtext"`
|
||||
GitHubUserID *string `gorm:"column:github_user_id;size:64;uniqueIndex:idx_users_github_id"`
|
||||
GiteaUserID *string `gorm:"column:gitea_user_id;size:64;uniqueIndex:idx_users_gitea_id"`
|
||||
LinuxdoUserID *string `gorm:"column:linuxdo_user_id;size:64;uniqueIndex:idx_users_linuxdo_id"`
|
||||
GoogleUserID *string `gorm:"column:google_user_id;size:128;uniqueIndex:idx_users_google_id"`
|
||||
MicrosoftUserID *string `gorm:"column:microsoft_user_id;size:128;uniqueIndex:idx_users_microsoft_id"`
|
||||
}
|
||||
|
||||
func (DBUser) TableName() string { return "users" }
|
||||
|
||||
func strPtrOrNil(s string) *string {
|
||||
t := strings.TrimSpace(s)
|
||||
if t == "" {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
func strDeref(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// DBUserFromRecord 将领域模型转换为 GORM 模型(导出供 migrate 工具使用)。
|
||||
func DBUserFromRecord(r models.UserRecord) DBUser {
|
||||
return dbUserFromRecord(r)
|
||||
@@ -141,6 +162,11 @@ func dbUserFromRecord(r models.UserRecord) DBUser {
|
||||
BannedAt: r.BannedAt,
|
||||
TokenEpoch: r.TokenEpoch,
|
||||
AuthClients: AuthClientSlice(r.AuthClients),
|
||||
GitHubUserID: strPtrOrNil(r.GitHubUserID),
|
||||
GiteaUserID: strPtrOrNil(r.GiteaUserID),
|
||||
LinuxdoUserID: strPtrOrNil(r.LinuxdoUserID),
|
||||
GoogleUserID: strPtrOrNil(r.GoogleUserID),
|
||||
MicrosoftUserID: strPtrOrNil(r.MicrosoftUserID),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +198,11 @@ func (d DBUser) toRecord() models.UserRecord {
|
||||
BannedAt: d.BannedAt,
|
||||
TokenEpoch: d.TokenEpoch,
|
||||
AuthClients: []models.AuthClientEntry(d.AuthClients),
|
||||
GitHubUserID: strDeref(d.GitHubUserID),
|
||||
GiteaUserID: strDeref(d.GiteaUserID),
|
||||
LinuxdoUserID: strDeref(d.LinuxdoUserID),
|
||||
GoogleUserID: strDeref(d.GoogleUserID),
|
||||
MicrosoftUserID: strDeref(d.MicrosoftUserID),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
509
sproutgate-backend/internal/storage/oauth.go
Normal file
509
sproutgate-backend/internal/storage/oauth.go
Normal 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
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -40,6 +41,42 @@ type CheckInConfig struct {
|
||||
RewardCoins int `json:"rewardCoins"`
|
||||
}
|
||||
|
||||
// OAuthConfig 第三方登录(GitHub / Gitea / Google / Microsoft / Linux.do),密钥也可由环境变量覆盖。
|
||||
type OAuthConfig struct {
|
||||
GitHubEnabled bool `json:"githubEnabled"`
|
||||
GiteaEnabled bool `json:"giteaEnabled"`
|
||||
GoogleEnabled bool `json:"googleEnabled"`
|
||||
MicrosoftEnabled bool `json:"microsoftEnabled"`
|
||||
LinuxdoEnabled bool `json:"linuxdoEnabled"`
|
||||
GitHubClientID string `json:"githubClientId"`
|
||||
GitHubClientSecret string `json:"githubClientSecret"`
|
||||
GiteaBaseURL string `json:"giteaBaseUrl"`
|
||||
GiteaClientID string `json:"giteaClientId"`
|
||||
GiteaClientSecret string `json:"giteaClientSecret"`
|
||||
GoogleClientID string `json:"googleClientId"`
|
||||
GoogleClientSecret string `json:"googleClientSecret"`
|
||||
MicrosoftClientID string `json:"microsoftClientId"`
|
||||
MicrosoftClientSecret string `json:"microsoftClientSecret"`
|
||||
MicrosoftTenant string `json:"microsoftTenant"`
|
||||
LinuxdoConnectBaseURL string `json:"linuxdoConnectBaseUrl"`
|
||||
LinuxdoClientID string `json:"linuxdoClientId"`
|
||||
LinuxdoClientSecret string `json:"linuxdoClientSecret"`
|
||||
GitHubLogoURL string `json:"githubLogoUrl"`
|
||||
GiteaLogoURL string `json:"giteaLogoUrl"`
|
||||
GoogleLogoURL string `json:"googleLogoUrl"`
|
||||
MicrosoftLogoURL string `json:"microsoftLogoUrl"`
|
||||
LinuxdoLogoURL string `json:"linuxdoLogoUrl"`
|
||||
AllowedReturnPrefixes []string `json:"allowedReturnPrefixes"`
|
||||
AllowOAuthSignUpWhenInviteRequired bool `json:"allowOAuthSignUpWhenInviteRequired"`
|
||||
}
|
||||
|
||||
// TurnstileConfig Cloudflare Turnstile 人机验证配置。
|
||||
type TurnstileConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
SiteKey string `json:"siteKey"`
|
||||
SecretKey string `json:"secretKey"`
|
||||
}
|
||||
|
||||
// ─── Store ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Store struct {
|
||||
@@ -50,7 +87,11 @@ type Store struct {
|
||||
emailConfig EmailConfig
|
||||
checkInConfig CheckInConfig
|
||||
registrationConfig RegistrationConfig
|
||||
oauthConfig OAuthConfig
|
||||
turnstileConfig TurnstileConfig
|
||||
mu sync.RWMutex
|
||||
hotReloadMu sync.Mutex
|
||||
lastHotReload time.Time
|
||||
}
|
||||
|
||||
// NewStore 接收已建立连接的 *gorm.DB,自动迁移表结构并加载配置。
|
||||
@@ -85,6 +126,12 @@ func NewStore(db *gorm.DB) (*Store, error) {
|
||||
if err := store.loadOrCreateRegistrationConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.loadOrCreateOAuthConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.loadOrCreateTurnstileConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
33
sproutgate-backend/internal/storage/turnstile.go
Normal file
33
sproutgate-backend/internal/storage/turnstile.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package storage
|
||||
|
||||
import "strings"
|
||||
|
||||
func (s *Store) GetTurnstileConfig() TurnstileConfig {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.turnstileConfig
|
||||
}
|
||||
|
||||
func (s *Store) UpdateTurnstileConfig(in TurnstileConfig) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if strings.TrimSpace(in.SecretKey) == "" {
|
||||
in.SecretKey = s.turnstileConfig.SecretKey
|
||||
}
|
||||
in.SiteKey = strings.TrimSpace(in.SiteKey)
|
||||
if err := s.setConfig("turnstile", in); err != nil {
|
||||
return err
|
||||
}
|
||||
s.turnstileConfig = in
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateTurnstileConfig() error {
|
||||
var cfg TurnstileConfig
|
||||
_, err := s.getConfig("turnstile", &cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.turnstileConfig = cfg
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user