Files
SproutGate/sproutgate-backend/internal/storage/oauth.go
2026-05-13 12:19:36 +08:00

510 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}