package auth import ( "bytes" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "regexp" "strings" "time" "yingce/backend/internal/kernel" "yingce/backend/internal/model" "yingce/backend/internal/outbound" "gorm.io/gorm" ) const linuxDOSettingKey = "linuxdo_oauth" var oauthUsernameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) type LinuxDOSettingRequest struct { Enabled bool `json:"enabled"` ClientID string `json:"clientId"` ClientSecret string `json:"clientSecret"` AuthorizationURL string `json:"authorizationUrl"` TokenURL string `json:"tokenUrl"` UserInfoURL string `json:"userInfoUrl"` RedirectURL string `json:"redirectUrl"` Scopes []string `json:"scopes"` ClientAuthMethod string `json:"clientAuthMethod"` SubjectField string `json:"subjectField"` UsernameField string `json:"usernameField"` DisplayNameField string `json:"displayNameField"` EmailField string `json:"emailField"` AvatarField string `json:"avatarField"` } type PublicLinuxDOSetting struct { Enabled bool `json:"enabled"` ClientID string `json:"clientId"` HasClientSecret bool `json:"hasClientSecret"` AuthorizationURL string `json:"authorizationUrl"` TokenURL string `json:"tokenUrl"` UserInfoURL string `json:"userInfoUrl"` RedirectURL string `json:"redirectUrl"` Scopes []string `json:"scopes"` ClientAuthMethod string `json:"clientAuthMethod"` SubjectField string `json:"subjectField"` UsernameField string `json:"usernameField"` DisplayNameField string `json:"displayNameField"` EmailField string `json:"emailField"` AvatarField string `json:"avatarField"` UpdatedBy string `json:"updatedBy"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type linuxDOSettingValue struct { Enabled bool `json:"enabled"` ClientID string `json:"clientId"` ClientSecret string `json:"clientSecret"` AuthorizationURL string `json:"authorizationUrl"` TokenURL string `json:"tokenUrl"` UserInfoURL string `json:"userInfoUrl"` RedirectURL string `json:"redirectUrl"` Scopes []string `json:"scopes"` ClientAuthMethod string `json:"clientAuthMethod"` SubjectField string `json:"subjectField"` UsernameField string `json:"usernameField"` DisplayNameField string `json:"displayNameField"` EmailField string `json:"emailField"` AvatarField string `json:"avatarField"` } type LinuxDOCallbackResult struct { Session *AuthSessionResult Next string } func (s *Service) AdminLinuxDOSetting(actor *model.User) (*PublicLinuxDOSetting, error) { if err := s.host.RequireAdmin(actor); err != nil { return nil, err } setting, value, err := s.readLinuxDOSetting() if err != nil { return nil, err } return publicLinuxDOSetting(setting, value), nil } func (s *Service) UpdateLinuxDOSetting(actor *model.User, req LinuxDOSettingRequest) (*PublicLinuxDOSetting, error) { if err := s.host.RequireAdmin(actor); err != nil { return nil, err } currentSetting, current, err := s.readLinuxDOSetting() if err != nil { return nil, err } next := normalizeLinuxDOSetting(linuxDOSettingValue{ Enabled: req.Enabled, ClientID: req.ClientID, ClientSecret: req.ClientSecret, AuthorizationURL: req.AuthorizationURL, TokenURL: req.TokenURL, UserInfoURL: req.UserInfoURL, RedirectURL: req.RedirectURL, Scopes: req.Scopes, ClientAuthMethod: req.ClientAuthMethod, SubjectField: req.SubjectField, UsernameField: req.UsernameField, DisplayNameField: req.DisplayNameField, EmailField: req.EmailField, AvatarField: req.AvatarField, }) if next.ClientSecret == "" { next.ClientSecret = current.ClientSecret } if err := validateLinuxDOSetting(next); err != nil { return nil, err } stored := next stored.ClientSecret, err = s.host.EncryptSecret(next.ClientSecret) if err != nil { return nil, err } encoded, err := json.Marshal(stored) if err != nil { return nil, err } setting := model.SystemSetting{Key: linuxDOSettingKey, ValueJSON: string(encoded), UpdatedBy: actor.ID} if currentSetting != nil { setting.CreatedAt = currentSetting.CreatedAt } if err := s.repo.SaveSystemSetting(&setting); err != nil { return nil, err } return publicLinuxDOSetting(&setting, next), nil } func (s *Service) LinuxDOEnabled() bool { _, setting, err := s.readLinuxDOSetting() return err == nil && setting.Enabled } func (s *Service) BeginLinuxDOLogin(nextPath string, acceptedTerms bool) (string, error) { count, err := s.repo.UserCount() if err != nil { return "", err } if count == 0 { return "", kernel.Forbidden("请先创建本地管理员账号,再开放 Linux.do 登录") } _, setting, err := s.readLinuxDOSetting() if err != nil { return "", err } if !setting.Enabled { return "", kernel.Forbidden("Linux.do 登录尚未启用") } state := RandomToken() verifier := RandomToken() challengeBytes := sha256.Sum256([]byte(verifier)) challenge := base64.RawURLEncoding.EncodeToString(challengeBytes[:]) if err := s.repo.CreateOAuthState(&model.OAuthState{ ID: kernel.NewID(), Provider: "linuxdo", StateHash: HashToken(state), CodeVerifier: verifier, NextPath: safeOAuthNext(nextPath), AcceptedTerms: acceptedTerms, ExpiresAt: time.Now().Add(10 * time.Minute), }); err != nil { return "", err } authorizeURL, err := url.Parse(setting.AuthorizationURL) if err != nil { return "", err } query := authorizeURL.Query() query.Set("client_id", setting.ClientID) query.Set("redirect_uri", setting.RedirectURL) query.Set("response_type", "code") if len(setting.Scopes) > 0 { query.Set("scope", strings.Join(setting.Scopes, " ")) } query.Set("state", state) query.Set("code_challenge", challenge) query.Set("code_challenge_method", "S256") authorizeURL.RawQuery = query.Encode() return authorizeURL.String(), nil } func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDOCallbackResult, error) { if strings.TrimSpace(stateValue) == "" || strings.TrimSpace(code) == "" { return nil, kernel.BadAuthRequest("Linux.do 登录回调缺少必要参数") } state, err := s.repo.ConsumeOAuthState("linuxdo", HashToken(stateValue)) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, kernel.BadAuthRequest("Linux.do 登录状态无效或已过期") } if err != nil { return nil, err } _, setting, err := s.readLinuxDOSetting() if err != nil { return nil, err } accessToken, err := exchangeLinuxDOCode(setting, code, state.CodeVerifier) if err != nil { return nil, err } profile, err := fetchLinuxDOProfile(setting, accessToken) if err != nil { return nil, err } subject := profileString(profile, setting.SubjectField) if subject == "" { return nil, errors.New("Linux.do 用户信息缺少稳定用户 ID") } providerUsername := profileString(profile, setting.UsernameField) displayName := kernel.FirstNonEmpty(profileString(profile, setting.DisplayNameField), providerUsername, "Linux.do 用户") avatarURL := profileString(profile, setting.AvatarField) identity, err := s.repo.UserIdentity("linuxdo", subject) var user *model.User if err == nil { user, err = s.repo.User(identity.UserID) if err != nil { return nil, err } identity.ProviderUsername = providerUsername identity.AvatarURL = avatarURL identity.UpdatedAt = time.Now() if err := s.repo.Save(identity); err != nil { return nil, err } } else if errors.Is(err, gorm.ErrRecordNotFound) { policy, policyErr := s.verificationPolicy() if policyErr != nil { return nil, policyErr } if policy.SMSAndEmailRegistration { return nil, kernel.Forbidden("平台要求短信和邮箱验证,请先通过注册页面创建账号;第三方登录不能绕过双重验证") } registrationEnabled, settingErr := s.RegistrationEnabled() if settingErr != nil { return nil, settingErr } if !registrationEnabled { return nil, kernel.Forbidden("管理员未开放新用户注册") } if !state.AcceptedTerms { return nil, kernel.BadAuthRequest("请先同意" + s.AgreementTitleForMessage()) } user, identity, err = s.createLinuxDOUser(subject, providerUsername, displayName, profileString(profile, setting.EmailField), avatarURL) if err != nil { return nil, err } if err := s.repo.CreateOAuthUser(user, identity); err != nil { return nil, err } } else { return nil, err } if user.Status != model.UserStatusActive { return nil, kernel.Forbidden("该账号已被禁用") } if err := s.host.EnsureSignupBonus(user.ID); err != nil { return nil, err } now := time.Now() user.LastLoginAt = &now user.UpdatedAt = now if err := s.repo.UpdateUserLoginTime(user.ID, now); err != nil { return nil, err } s.host.RecordActivity(user.ID, "login", 1) session, err := s.createAuthSession(user) if err != nil { return nil, err } return &LinuxDOCallbackResult{Session: session, Next: safeOAuthNext(state.NextPath)}, nil } func (s *Service) createLinuxDOUser(subject string, providerUsername string, displayName string, email string, avatarURL string) (*model.User, *model.UserIdentity, error) { base := oauthUsernameSanitizer.ReplaceAllString(strings.TrimSpace(providerUsername), "_") base = strings.Trim(base, "_-") if len(base) < 3 { base = "linuxdo_" + shortSubject(subject) } if len(base) > 24 { base = base[:24] } username := base if existing, err := s.repo.UserByUsername(username); err == nil && existing != nil { username = kernel.TruncateRunes(base, 23) + "_" + shortSubject(subject) } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, err } email = NormalizeEmail(email) if email != "" { if ValidateEmail(email) != nil { email = "" } else if existing, err := s.repo.UserByEmail(email); err == nil && existing != nil { // 相同邮箱不自动合并身份,避免第三方邮箱状态不明导致账号接管。 email = "" } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, err } } user := &model.User{ID: kernel.NewID(), Username: username, Email: email, DisplayName: NormalizeDisplayName(displayName, username), Role: model.UserRoleUser, Status: model.UserStatusActive} identity := &model.UserIdentity{ID: kernel.NewID(), UserID: user.ID, Provider: "linuxdo", Subject: subject, ProviderUsername: providerUsername, AvatarURL: avatarURL} return user, identity, nil } func (s *Service) readLinuxDOSetting() (*model.SystemSetting, linuxDOSettingValue, error) { setting, err := s.repo.SystemSetting(linuxDOSettingKey) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, defaultLinuxDOSetting(), nil } if err != nil { return nil, linuxDOSettingValue{}, err } value := defaultLinuxDOSetting() if strings.TrimSpace(setting.ValueJSON) != "" { if err := json.Unmarshal([]byte(setting.ValueJSON), &value); err != nil { return nil, linuxDOSettingValue{}, errors.New("Linux.do OAuth 配置格式无效") } } value.ClientSecret, err = s.host.DecryptSecret(value.ClientSecret) if err != nil { return nil, linuxDOSettingValue{}, err } return setting, normalizeLinuxDOSetting(value), nil } func validateLinuxDOSetting(value linuxDOSettingValue) error { if !value.Enabled { return nil } if value.ClientID == "" || value.ClientSecret == "" || value.AuthorizationURL == "" || value.TokenURL == "" || value.UserInfoURL == "" || value.RedirectURL == "" { return kernel.BadAuthRequest("启用 Linux.do 登录前请完整填写 Client、端点和回调配置") } for _, rawURL := range []string{value.AuthorizationURL, value.TokenURL, value.UserInfoURL} { parsed, err := url.Parse(rawURL) if err != nil || parsed.Scheme != "https" || parsed.Host == "" { return kernel.BadAuthRequest("Linux.do 授权、Token 和用户信息地址必须是有效的 HTTPS URL") } } redirectURL, err := url.Parse(value.RedirectURL) if err != nil || redirectURL.Host == "" || (redirectURL.Scheme != "https" && !(redirectURL.Scheme == "http" && isLoopbackOAuthHost(redirectURL.Hostname()))) { return kernel.BadAuthRequest("Linux.do 回调地址必须使用 HTTPS,本地回环地址可使用 HTTP") } if value.ClientAuthMethod != "client_secret_post" && value.ClientAuthMethod != "client_secret_basic" { return kernel.BadAuthRequest("请选择有效的 OAuth 客户端鉴权方式") } return nil } func isLoopbackOAuthHost(host string) bool { host = strings.ToLower(strings.TrimSpace(host)) return host == "localhost" || host == "127.0.0.1" || host == "::1" } func normalizeLinuxDOSetting(value linuxDOSettingValue) linuxDOSettingValue { value.ClientID = strings.TrimSpace(value.ClientID) value.ClientSecret = strings.TrimSpace(value.ClientSecret) value.AuthorizationURL = strings.TrimSpace(value.AuthorizationURL) value.TokenURL = strings.TrimSpace(value.TokenURL) value.UserInfoURL = strings.TrimSpace(value.UserInfoURL) value.RedirectURL = strings.TrimSpace(value.RedirectURL) value.Scopes = kernel.UniqueNonEmpty(value.Scopes) value.ClientAuthMethod = strings.TrimSpace(value.ClientAuthMethod) value.SubjectField = strings.TrimSpace(value.SubjectField) value.UsernameField = strings.TrimSpace(value.UsernameField) value.DisplayNameField = strings.TrimSpace(value.DisplayNameField) value.EmailField = strings.TrimSpace(value.EmailField) value.AvatarField = strings.TrimSpace(value.AvatarField) if value.ClientAuthMethod == "" { value.ClientAuthMethod = "client_secret_post" } if value.SubjectField == "" { value.SubjectField = "id" } if value.UsernameField == "" { value.UsernameField = "username" } if value.DisplayNameField == "" { value.DisplayNameField = "name" } if value.EmailField == "" { value.EmailField = "email" } if value.AvatarField == "" { value.AvatarField = "avatar_url" } return value } func defaultLinuxDOSetting() linuxDOSettingValue { return normalizeLinuxDOSetting(linuxDOSettingValue{}) } func publicLinuxDOSetting(setting *model.SystemSetting, value linuxDOSettingValue) *PublicLinuxDOSetting { result := &PublicLinuxDOSetting{ Enabled: value.Enabled, ClientID: value.ClientID, HasClientSecret: value.ClientSecret != "", AuthorizationURL: value.AuthorizationURL, TokenURL: value.TokenURL, UserInfoURL: value.UserInfoURL, RedirectURL: value.RedirectURL, Scopes: value.Scopes, ClientAuthMethod: value.ClientAuthMethod, SubjectField: value.SubjectField, UsernameField: value.UsernameField, DisplayNameField: value.DisplayNameField, EmailField: value.EmailField, AvatarField: value.AvatarField, } if setting != nil { result.UpdatedBy = setting.UpdatedBy result.CreatedAt = setting.CreatedAt result.UpdatedAt = setting.UpdatedAt } return result } func exchangeLinuxDOCode(setting linuxDOSettingValue, code string, verifier string) (string, error) { if _, err := outbound.ValidateOutboundURL(setting.TokenURL); err != nil { return "", err } form := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "client_id": {setting.ClientID}, "redirect_uri": {setting.RedirectURL}, "code_verifier": {verifier}, } if setting.ClientAuthMethod == "client_secret_post" { form.Set("client_secret", setting.ClientSecret) } req, err := http.NewRequest(http.MethodPost, setting.TokenURL, strings.NewReader(form.Encode())) if err != nil { return "", err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") if setting.ClientAuthMethod == "client_secret_basic" { req.SetBasicAuth(setting.ClientID, setting.ClientSecret) } resp, err := outbound.OutboundHTTPClient(20 * time.Second).Do(req) if err != nil { return "", fmt.Errorf("Linux.do Token 请求失败:%w", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return "", err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", fmt.Errorf("Linux.do Token 请求失败:HTTP %d", resp.StatusCode) } var payload struct { AccessToken string `json:"access_token"` } if err := json.Unmarshal(body, &payload); err != nil || payload.AccessToken == "" { return "", errors.New("Linux.do Token 响应无效") } return payload.AccessToken, nil } func fetchLinuxDOProfile(setting linuxDOSettingValue, accessToken string) (map[string]any, error) { if _, err := outbound.ValidateOutboundURL(setting.UserInfoURL); err != nil { return nil, err } req, err := http.NewRequest(http.MethodGet, setting.UserInfoURL, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "application/json") resp, err := outbound.OutboundHTTPClient(20 * time.Second).Do(req) if err != nil { return nil, fmt.Errorf("Linux.do 用户信息请求失败:%w", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) if err != nil { return nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("Linux.do 用户信息请求失败:HTTP %d", resp.StatusCode) } decoder := json.NewDecoder(bytes.NewReader(body)) decoder.UseNumber() var profile map[string]any if err := decoder.Decode(&profile); err != nil { return nil, errors.New("Linux.do 用户信息响应无效") } return profile, nil } func profileString(profile map[string]any, field string) string { var value any = profile for _, segment := range strings.Split(field, ".") { object, ok := value.(map[string]any) if !ok { return "" } value, ok = object[segment] if !ok || value == nil { return "" } } return strings.TrimSpace(fmt.Sprint(value)) } func safeOAuthNext(value string) string { value = strings.TrimSpace(value) parsed, err := url.Parse(value) if value == "" || err != nil || parsed.IsAbs() || parsed.Host != "" || !strings.HasPrefix(parsed.Path, "/") || strings.HasPrefix(value, "//") || strings.Contains(value, "\\") { return "/create" } return parsed.RequestURI() } func shortSubject(value string) string { sum := sha256.Sum256([]byte(value)) return fmt.Sprintf("%x", sum[:4]) }