510 lines
15 KiB
Go
510 lines
15 KiB
Go
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
|
||
}
|