fix: 加固 LinuxDo OAuth 登录安全与配置校验

This commit is contained in:
shaw
2026-01-09 19:32:06 +08:00
parent 707061efac
commit f060db0b30
14 changed files with 184 additions and 205 deletions

View File

@@ -324,10 +324,10 @@ type TurnstileConfig struct {
Required bool `mapstructure:"required"`
}
// LinuxDoConnectConfig controls LinuxDo Connect OAuth login (end-user SSO).
// LinuxDoConnectConfig 用于 LinuxDo Connect OAuth 登录(终端用户 SSO)。
//
// Note: This is NOT the same as upstream account OAuth (e.g. OpenAI/Gemini).
// It is used for logging in to Sub2API itself.
// 注意:这与上游账号的 OAuth例如 OpenAI/Gemini 账号接入)不是一回事。
// 这里是用于登录 Sub2API 本身的用户体系。
type LinuxDoConnectConfig struct {
Enabled bool `mapstructure:"enabled"`
ClientID string `mapstructure:"client_id"`
@@ -336,13 +336,13 @@ type LinuxDoConnectConfig struct {
TokenURL string `mapstructure:"token_url"`
UserInfoURL string `mapstructure:"userinfo_url"`
Scopes string `mapstructure:"scopes"`
RedirectURL string `mapstructure:"redirect_url"` // backend callback URL registered at the provider
FrontendRedirectURL string `mapstructure:"frontend_redirect_url"` // frontend route to receive token (default: /auth/linuxdo/callback)
RedirectURL string `mapstructure:"redirect_url"` // 后端回调地址(需在提供方后台登记)
FrontendRedirectURL string `mapstructure:"frontend_redirect_url"` // 前端接收 token 的路由(默认:/auth/linuxdo/callback
TokenAuthMethod string `mapstructure:"token_auth_method"` // client_secret_post / client_secret_basic / none
UsePKCE bool `mapstructure:"use_pkce"`
// Optional: gjson paths to extract fields from userinfo JSON.
// When empty, the server tries a set of common keys.
// 可选:用于从 userinfo JSON 中提取字段的 gjson 路径。
// 为空时,服务端会尝试一组常见字段名。
UserInfoEmailPath string `mapstructure:"userinfo_email_path"`
UserInfoIDPath string `mapstructure:"userinfo_id_path"`
UserInfoUsernamePath string `mapstructure:"userinfo_username_path"`
@@ -464,7 +464,8 @@ func Load() (*Config, error) {
return &cfg, nil
}
func validateAbsoluteHTTPURL(raw string) error {
// ValidateAbsoluteHTTPURL 校验一个绝对 http(s) URL禁止 fragment
func ValidateAbsoluteHTTPURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("empty url")
@@ -488,7 +489,10 @@ func validateAbsoluteHTTPURL(raw string) error {
return nil
}
func validateFrontendRedirectURL(raw string) error {
// ValidateFrontendRedirectURL 校验前端回调地址:
// - 允许同源相对路径(以 / 开头)
// - 或绝对 http(s) URL禁止 fragment
func ValidateFrontendRedirectURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("empty url")
@@ -584,7 +588,7 @@ func setDefaults() {
// Turnstile
viper.SetDefault("turnstile.required", false)
// LinuxDo Connect OAuth login (end-user SSO)
// LinuxDo Connect OAuth 登录(终端用户 SSO
viper.SetDefault("linuxdo_connect.enabled", false)
viper.SetDefault("linuxdo_connect.client_id", "")
viper.SetDefault("linuxdo_connect.client_secret", "")
@@ -743,19 +747,19 @@ func (c *Config) Validate() error {
return fmt.Errorf("linuxdo_connect.frontend_redirect_url is required when linuxdo_connect.enabled=true")
}
if err := validateAbsoluteHTTPURL(c.LinuxDo.AuthorizeURL); err != nil {
if err := ValidateAbsoluteHTTPURL(c.LinuxDo.AuthorizeURL); err != nil {
return fmt.Errorf("linuxdo_connect.authorize_url invalid: %w", err)
}
if err := validateAbsoluteHTTPURL(c.LinuxDo.TokenURL); err != nil {
if err := ValidateAbsoluteHTTPURL(c.LinuxDo.TokenURL); err != nil {
return fmt.Errorf("linuxdo_connect.token_url invalid: %w", err)
}
if err := validateAbsoluteHTTPURL(c.LinuxDo.UserInfoURL); err != nil {
if err := ValidateAbsoluteHTTPURL(c.LinuxDo.UserInfoURL); err != nil {
return fmt.Errorf("linuxdo_connect.userinfo_url invalid: %w", err)
}
if err := validateAbsoluteHTTPURL(c.LinuxDo.RedirectURL); err != nil {
if err := ValidateAbsoluteHTTPURL(c.LinuxDo.RedirectURL); err != nil {
return fmt.Errorf("linuxdo_connect.redirect_url invalid: %w", err)
}
if err := validateFrontendRedirectURL(c.LinuxDo.FrontendRedirectURL); err != nil {
if err := ValidateFrontendRedirectURL(c.LinuxDo.FrontendRedirectURL); err != nil {
return fmt.Errorf("linuxdo_connect.frontend_redirect_url invalid: %w", err)
}

View File

@@ -2,10 +2,10 @@ package admin
import (
"log"
"net/url"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
@@ -94,7 +94,7 @@ type UpdateSettingsRequest struct {
TurnstileSiteKey string `json:"turnstile_site_key"`
TurnstileSecretKey string `json:"turnstile_secret_key"`
// LinuxDo Connect OAuth login (end-user SSO)
// LinuxDo Connect OAuth 登录(终端用户 SSO
LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"`
LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"`
LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"`
@@ -191,12 +191,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response.BadRequest(c, "LinuxDo Redirect URL is required when enabled")
return
}
if !isAbsoluteHTTPURL(req.LinuxDoConnectRedirectURL) {
if err := config.ValidateAbsoluteHTTPURL(req.LinuxDoConnectRedirectURL); err != nil {
response.BadRequest(c, "LinuxDo Redirect URL must be an absolute http(s) URL")
return
}
// If client_secret not provided, keep existing value (if any).
// 如果未提供 client_secret,则保留现有值(如有)。
if req.LinuxDoConnectClientSecret == "" {
if previousSettings.LinuxDoConnectClientSecret == "" {
response.BadRequest(c, "LinuxDo Client Secret is required when enabled")
@@ -407,33 +407,6 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
return changed
}
func isAbsoluteHTTPURL(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
if strings.HasPrefix(raw, "//") {
return false
}
u, err := url.Parse(raw)
if err != nil {
return false
}
if !u.IsAbs() {
return false
}
if !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") {
return false
}
if strings.TrimSpace(u.Host) == "" {
return false
}
if u.Fragment != "" {
return false
}
return true
}
// TestSMTPRequest 测试SMTP连接请求
type TestSMTPRequest struct {
SMTPHost string `json:"smtp_host" binding:"required"`

View File

@@ -17,6 +17,7 @@ import (
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/imroc/req/v3"
@@ -66,7 +67,7 @@ func (e *linuxDoTokenExchangeError) Error() string {
return strings.Join(parts, " ")
}
// LinuxDoOAuthStart starts the LinuxDo Connect OAuth login flow.
// LinuxDoOAuthStart 启动 LinuxDo Connect OAuth 登录流程。
// GET /api/v1/auth/oauth/linuxdo/start?redirect=/dashboard
func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
cfg, err := h.getLinuxDoOAuthConfig(c.Request.Context())
@@ -116,7 +117,7 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
c.Redirect(http.StatusFound, authURL)
}
// LinuxDoOAuthCallback handles the OAuth callback, creates/logins the user, then redirects to frontend.
// LinuxDoOAuthCallback 处理 OAuth 回调:创建/登录用户,然后重定向到前端。
// GET /api/v1/auth/oauth/linuxdo/callback?code=...&state=...
func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
cfg, cfgErr := h.getLinuxDoOAuthConfig(c.Request.Context())
@@ -197,16 +198,22 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
return
}
email, username, _, err := linuxDoFetchUserInfo(c.Request.Context(), cfg, tokenResp)
email, username, subject, err := linuxDoFetchUserInfo(c.Request.Context(), cfg, tokenResp)
if err != nil {
log.Printf("[LinuxDo OAuth] userinfo fetch failed: %v", err)
redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch user info", "")
return
}
// 安全考虑:不要把第三方返回的 email 直接映射到本地账号(可能与本地邮箱用户冲突导致账号被接管)。
// 统一使用基于 subject 的稳定合成邮箱来做账号绑定。
if subject != "" {
email = linuxDoSyntheticEmail(subject)
}
jwtToken, _, err := h.authService.LoginOrRegisterOAuth(c.Request.Context(), email, username)
if err != nil {
// Avoid leaking internal details to the client; keep structured reason for frontend.
// 避免把内部细节泄露给客户端;给前端保留结构化原因与提示信息即可。
redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err))
return
}
@@ -352,9 +359,8 @@ func linuxDoParseUserInfo(body string, cfg config.LinuxDoConnectConfig) (email s
email = strings.TrimSpace(email)
if email == "" {
// LinuxDo Connect userinfo does not necessarily provide email. To keep compatibility with the
// existing user schema (email is required/unique), use a stable synthetic email.
email = fmt.Sprintf("linuxdo-%s@linuxdo-connect.invalid", subject)
// LinuxDo Connect userinfo 可能不提供 email。为兼容现有用户模型email 必填且唯一),使用稳定的合成邮箱。
email = linuxDoSyntheticEmail(subject)
}
username = strings.TrimSpace(username)
@@ -403,7 +409,7 @@ func redirectOAuthError(c *gin.Context, frontendCallback string, code string, me
func redirectWithFragment(c *gin.Context, frontendCallback string, fragment url.Values) {
u, err := url.Parse(frontendCallback)
if err != nil {
// Fallback: best-effort redirect.
// 兜底:尽力跳转到默认页面,避免卡死在回调页。
c.Redirect(http.StatusFound, linuxDoOAuthDefaultRedirectTo)
return
}
@@ -545,7 +551,7 @@ func sanitizeFrontendRedirectPath(path string) string {
if len(path) > linuxDoOAuthMaxRedirectLen {
return ""
}
// Only allow same-origin relative paths (avoid open redirect).
// 只允许同源相对路径(避免开放重定向)。
if !strings.HasPrefix(path, "/") {
return ""
}
@@ -663,3 +669,11 @@ func isSafeLinuxDoSubject(subject string) bool {
}
return true
}
func linuxDoSyntheticEmail(subject string) string {
subject = strings.TrimSpace(subject)
if subject == "" {
return ""
}
return "linuxdo-" + subject + service.LinuxDoConnectSyntheticEmailDomain
}

View File

@@ -32,8 +32,6 @@ var (
ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
)
const linuxDoSyntheticEmailDomain = "@linuxdo-connect.invalid"
// maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。
const maxTokenLength = 8192
@@ -87,7 +85,7 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
return "", nil, ErrRegDisabled
}
// Prevent users from registering emails reserved for synthetic OAuth accounts.
// 防止用户注册 LinuxDo OAuth 合成邮箱,避免第三方登录与本地账号发生碰撞。
if isReservedEmail(email) {
return "", nil, ErrEmailReserved
}
@@ -339,11 +337,12 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string
return token, user, nil
}
// LoginOrRegisterOAuth logs a user in by email (trusted from an OAuth provider) or creates a new user.
// LoginOrRegisterOAuth 用于第三方 OAuth/SSO 登录:
// - 如果邮箱已存在:直接登录(不需要本地密码)
// - 如果邮箱不存在:创建新用户并登录
//
// This is used by end-user OAuth/SSO login flows (e.g. LinuxDo Connect), and intentionally does
// NOT require the local password. A random password hash is generated for new users to satisfy
// the existing database constraint.
// 注意:该函数用于“终端用户登录 Sub2API 本身”的场景(不同于上游账号的 OAuth例如 OpenAI/Gemini
// 为了满足现有数据库约束(需要密码哈希),新用户会生成随机密码并进行哈希保存。
func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username string) (string, *User, error) {
email = strings.TrimSpace(email)
if email == "" || len(email) > 255 {
@@ -361,7 +360,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
user, err := s.userRepo.GetByEmail(ctx, email)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
// Treat OAuth-first login as registration.
// OAuth 首次登录视为注册。
if s.settingService != nil && !s.settingService.IsRegistrationEnabled(ctx) {
return "", nil, ErrRegDisabled
}
@@ -376,7 +375,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
return "", nil, fmt.Errorf("hash password: %w", err)
}
// Defaults for new users.
// 新用户默认值。
defaultBalance := s.cfg.Default.UserBalance
defaultConcurrency := s.cfg.Default.UserConcurrency
if s.settingService != nil {
@@ -396,7 +395,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
if err := s.userRepo.Create(ctx, newUser); err != nil {
if errors.Is(err, ErrEmailExists) {
// Race: user created between GetByEmail and Create.
// 并发场景:GetByEmail Create 之间用户被创建。
user, err = s.userRepo.GetByEmail(ctx, email)
if err != nil {
log.Printf("[Auth] Database error getting user after conflict: %v", err)
@@ -419,7 +418,7 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
return "", nil, ErrUserNotActive
}
// Best-effort: fill username when empty.
// 尽力补全:当用户名为空时,使用第三方返回的用户名回填。
if user.Username == "" && username != "" {
user.Username = username
if err := s.userRepo.Update(ctx, user); err != nil {
@@ -489,7 +488,7 @@ func randomHexString(byteLength int) (string, error) {
func isReservedEmail(email string) bool {
normalized := strings.ToLower(strings.TrimSpace(email))
return strings.HasSuffix(normalized, linuxDoSyntheticEmailDomain)
return strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain)
}
// GenerateToken 生成JWT token

View File

@@ -106,12 +106,16 @@ const (
SettingKeyEnableIdentityPatch = "enable_identity_patch"
SettingKeyIdentityPatchPrompt = "identity_patch_prompt"
// LinuxDo Connect OAuth login (end-user SSO)
// LinuxDo Connect OAuth 登录(终端用户 SSO
SettingKeyLinuxDoConnectEnabled = "linuxdo_connect_enabled"
SettingKeyLinuxDoConnectClientID = "linuxdo_connect_client_id"
SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret"
SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url"
)
// LinuxDoConnectSyntheticEmailDomain 是 LinuxDo Connect 用户的合成邮箱后缀RFC 保留域名)。
// 目的:避免第三方登录返回的用户标识与本地真实邮箱发生碰撞,进而造成账号被接管的风险。
const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid"
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
const AdminAPIKeyPrefix = "admin-"

View File

@@ -121,7 +121,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey
}
// LinuxDo Connect OAuth login (end-user SSO)
// LinuxDo Connect OAuth 登录(终端用户 SSO
updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled)
updates[SettingKeyLinuxDoConnectClientID] = settings.LinuxDoConnectClientID
updates[SettingKeyLinuxDoConnectRedirectURL] = settings.LinuxDoConnectRedirectURL
@@ -289,9 +289,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
result.SMTPPassword = settings[SettingKeySMTPPassword]
result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey]
// LinuxDo Connect settings:
// - Backward compatible with config.yaml/env (so existing deployments don't get disabled by accident)
// - Can be overridden and persisted via admin "system settings" (stored in DB)
// LinuxDo Connect 设置:
// - 兼容 config.yaml/env避免老部署因为未迁移到数据库设置而被意外关闭
// - 支持在后台“系统设置”中覆盖并持久化(存储于 DB
linuxDoBase := config.LinuxDoConnectConfig{}
if s.cfg != nil {
linuxDoBase = s.cfg.LinuxDo
@@ -339,11 +339,11 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
return result
}
// GetLinuxDoConnectOAuthConfig returns the effective LinuxDo Connect config for login.
// GetLinuxDoConnectOAuthConfig 返回用于登录的“最终生效” LinuxDo Connect 配置。
//
// Precedence:
// - If a corresponding system setting key exists, it overrides config.yaml/env values.
// - Otherwise, it falls back to config.yaml/env values.
// 优先级:
// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
// - 否则回退到 config.yaml/env 的值
func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) {
if s == nil || s.cfg == nil {
return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
@@ -379,7 +379,7 @@ func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (conf
return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
}
// Best-effort sanity check (avoid redirecting users into a broken OAuth flow).
// 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。
if strings.TrimSpace(effective.ClientID) == "" {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
}
@@ -399,6 +399,22 @@ func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (conf
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured")
}
if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid")
}
if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid")
}
if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid")
}
if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid")
}
if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
}
method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod))
switch method {
case "", "client_secret_post", "client_secret_basic":

View File

@@ -18,7 +18,7 @@ type SystemSettings struct {
TurnstileSecretKey string
TurnstileSecretKeyConfigured bool
// LinuxDo Connect OAuth login (end-user SSO)
// LinuxDo Connect OAuth 登录(终端用户 SSO
LinuxDoConnectEnabled bool
LinuxDoConnectClientID string
LinuxDoConnectClientSecret string