Files
oneterm/backend/internal/service/web_auth.go
2025-07-30 14:22:43 +08:00

498 lines
14 KiB
Go

package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"go.uber.org/zap"
"github.com/samber/lo"
"github.com/veops/oneterm/pkg/logger"
)
// WebAuthService handles Web authentication
type WebAuthService struct {
strategies []WebAuthStrategy
}
// WebAuthStrategy defines the interface for Web authentication strategies
type WebAuthStrategy interface {
Name() string
Priority() int
CanHandle(ctx context.Context, siteInfo *WebSiteInfo) bool
Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error)
}
// WebSiteInfo contains information about the target Web site
type WebSiteInfo struct {
URL string
HTMLContent string
Headers http.Header
StatusCode int
LoginForms []WebLoginForm
}
// WebLoginForm represents a login form found on the page
type WebLoginForm struct {
Action string `json:"action"`
Method string `json:"method"`
UsernameField WebFormField `json:"username_field"`
PasswordField WebFormField `json:"password_field"`
SubmitButton WebFormField `json:"submit_button"`
AdditionalFields []WebFormField `json:"additional_fields"`
CSRFToken string `json:"csrf_token"`
}
// WebFormField represents a form field
type WebFormField struct {
Name string `json:"name"`
ID string `json:"id"`
Type string `json:"type"`
Value string `json:"value"`
Selector string `json:"selector"`
Placeholder string `json:"placeholder"`
}
// WebCredentials contains authentication credentials
type WebCredentials struct {
Username string
Password string
}
// WebAuthResult contains authentication result
type WebAuthResult struct {
Success bool
Message string
Cookies []*http.Cookie
RedirectURL string
SessionData map[string]interface{}
}
// NewWebAuthService creates a new Web authentication service
func NewWebAuthService() *WebAuthService {
service := &WebAuthService{
strategies: []WebAuthStrategy{
&HTTPBasicAuthStrategy{},
&SmartFormAuthStrategy{},
&APILoginAuthStrategy{},
},
}
return service
}
// AnalyzeSite analyzes a Web site for authentication methods
func (s *WebAuthService) AnalyzeSite(ctx context.Context, targetURL string) (*WebSiteInfo, error) {
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Don't follow redirects during analysis
return http.ErrUseLastResponse
},
}
resp, err := client.Get(targetURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch site: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
siteInfo := &WebSiteInfo{
URL: targetURL,
HTMLContent: string(body),
Headers: resp.Header,
StatusCode: resp.StatusCode,
}
// Analyze HTML for login forms
if strings.Contains(resp.Header.Get("Content-Type"), "text/html") {
forms, err := s.analyzeLoginForms(string(body))
if err != nil {
logger.L().Warn("Failed to analyze login forms", zap.Error(err))
} else {
siteInfo.LoginForms = forms
}
}
return siteInfo, nil
}
// SelectBestStrategy selects the best authentication strategy for a site
func (s *WebAuthService) SelectBestStrategy(ctx context.Context, siteInfo *WebSiteInfo) WebAuthStrategy {
var bestStrategy WebAuthStrategy
highestPriority := -1
for _, strategy := range s.strategies {
if strategy.CanHandle(ctx, siteInfo) && strategy.Priority() > highestPriority {
bestStrategy = strategy
highestPriority = strategy.Priority()
}
}
return bestStrategy
}
// Authenticate performs authentication using the best available strategy
func (s *WebAuthService) Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) {
strategy := s.SelectBestStrategy(ctx, siteInfo)
if strategy == nil {
return &WebAuthResult{
Success: false,
Message: "No suitable authentication strategy found",
}, nil
}
return strategy.Authenticate(ctx, credentials, siteInfo)
}
// AuthenticateWithRetry performs authentication with automatic account retry
func (s *WebAuthService) AuthenticateWithRetry(ctx context.Context, accounts []WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) {
if len(accounts) == 0 {
return &WebAuthResult{
Success: false,
Message: "No accounts available for authentication",
}, nil
}
strategy := s.SelectBestStrategy(ctx, siteInfo)
if strategy == nil {
return &WebAuthResult{
Success: false,
Message: "No suitable authentication strategy found",
}, nil
}
var lastError error
var lastResult *WebAuthResult
// 尝试每个账号,直到成功
for i, credentials := range accounts {
logger.L().Info("Attempting authentication",
zap.String("strategy", strategy.Name()),
zap.String("username", credentials.Username),
zap.Int("attempt", i+1),
zap.Int("total_accounts", len(accounts)))
result, err := strategy.Authenticate(ctx, &credentials, siteInfo)
if err != nil {
lastError = err
logger.L().Warn("Authentication error",
zap.String("username", credentials.Username),
zap.Error(err))
continue
}
lastResult = result
if result.Success {
logger.L().Info("Authentication successful",
zap.String("username", credentials.Username),
zap.Int("attempt", i+1))
return result, nil
}
logger.L().Warn("Authentication failed",
zap.String("username", credentials.Username),
zap.String("reason", result.Message))
}
// 所有账号都失败了
if lastError != nil {
return nil, fmt.Errorf("all authentication attempts failed, last error: %w", lastError)
}
if lastResult != nil {
return lastResult, nil
}
return &WebAuthResult{
Success: false,
Message: "All configured accounts failed to authenticate",
}, nil
}
// analyzeLoginForms analyzes HTML content for login forms
func (s *WebAuthService) analyzeLoginForms(htmlContent string) ([]WebLoginForm, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))
if err != nil {
return nil, err
}
var forms []WebLoginForm
doc.Find("form").Each(func(i int, formSel *goquery.Selection) {
form := WebLoginForm{
Method: strings.ToUpper(formSel.AttrOr("method", "GET")),
Action: formSel.AttrOr("action", ""),
}
// Find username field
formSel.Find("input").Each(func(j int, inputSel *goquery.Selection) {
inputType := strings.ToLower(inputSel.AttrOr("type", "text"))
inputName := inputSel.AttrOr("name", "")
inputID := inputSel.AttrOr("id", "")
placeholder := inputSel.AttrOr("placeholder", "")
field := WebFormField{
Name: inputName,
ID: inputID,
Type: inputType,
Selector: s.generateSelector(inputSel),
Placeholder: placeholder,
}
// Identify field type based on various indicators
if s.isUsernameField(inputType, inputName, inputID, placeholder) && form.UsernameField.Name == "" {
form.UsernameField = field
} else if inputType == "password" && form.PasswordField.Name == "" {
form.PasswordField = field
}
})
// Find submit button
formSel.Find("button, input[type=submit]").Each(func(j int, btnSel *goquery.Selection) {
if form.SubmitButton.Name == "" {
form.SubmitButton = WebFormField{
Name: btnSel.AttrOr("name", ""),
ID: btnSel.AttrOr("id", ""),
Type: btnSel.AttrOr("type", "submit"),
Selector: s.generateSelector(btnSel),
}
}
})
// Only include forms that have both username and password fields
if form.UsernameField.Name != "" && form.PasswordField.Name != "" {
forms = append(forms, form)
}
})
return forms, nil
}
// isUsernameField determines if a field is likely a username field
func (s *WebAuthService) isUsernameField(inputType, name, id, placeholder string) bool {
if inputType == "password" {
return false
}
keywords := []string{"user", "login", "email", "account", "name"}
text := strings.ToLower(name + id + placeholder)
return lo.SomeBy(keywords, func(keyword string) bool {
return strings.Contains(text, keyword)
})
}
// generateSelector generates a CSS selector for an element
func (s *WebAuthService) generateSelector(sel *goquery.Selection) string {
if id := sel.AttrOr("id", ""); id != "" {
return "#" + id
}
if name := sel.AttrOr("name", ""); name != "" {
return fmt.Sprintf(`[name="%s"]`, name)
}
if class := sel.AttrOr("class", ""); class != "" {
classes := strings.Split(class, " ")
if len(classes) > 0 {
return "." + strings.Join(classes, ".")
}
}
return sel.Get(0).Data // fallback to tag name
}
// HTTPBasicAuthStrategy implements HTTP Basic Authentication
type HTTPBasicAuthStrategy struct{}
func (s *HTTPBasicAuthStrategy) Name() string { return "http_basic" }
func (s *HTTPBasicAuthStrategy) Priority() int { return 10 }
func (s *HTTPBasicAuthStrategy) CanHandle(ctx context.Context, siteInfo *WebSiteInfo) bool {
return siteInfo.StatusCode == 401 &&
strings.Contains(siteInfo.Headers.Get("WWW-Authenticate"), "Basic")
}
func (s *HTTPBasicAuthStrategy) Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", siteInfo.URL, nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(credentials.Username, credentials.Password)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
success := resp.StatusCode != 401
return &WebAuthResult{
Success: success,
Message: fmt.Sprintf("HTTP Basic auth %s", map[bool]string{true: "succeeded", false: "failed"}[success]),
Cookies: resp.Cookies(),
}, nil
}
// SmartFormAuthStrategy implements intelligent form-based authentication
type SmartFormAuthStrategy struct{}
func (s *SmartFormAuthStrategy) Name() string { return "smart_form" }
func (s *SmartFormAuthStrategy) Priority() int { return 5 }
func (s *SmartFormAuthStrategy) CanHandle(ctx context.Context, siteInfo *WebSiteInfo) bool {
return len(siteInfo.LoginForms) > 0
}
func (s *SmartFormAuthStrategy) Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) {
if len(siteInfo.LoginForms) == 0 {
return nil, fmt.Errorf("no login forms found")
}
form := siteInfo.LoginForms[0] // Use the first form found
// Prepare form data
formData := url.Values{}
formData.Set(form.UsernameField.Name, credentials.Username)
formData.Set(form.PasswordField.Name, credentials.Password)
// Add submit button if it has a name
if form.SubmitButton.Name != "" {
formData.Set(form.SubmitButton.Name, "")
}
// Determine the target URL
actionURL := form.Action
if actionURL == "" || strings.HasPrefix(actionURL, "/") {
baseURL, _ := url.Parse(siteInfo.URL)
if actionURL == "" {
actionURL = siteInfo.URL
} else {
actionURL = baseURL.Scheme + "://" + baseURL.Host + actionURL
}
}
// Create HTTP client
client := &http.Client{Timeout: 30 * time.Second}
// Submit the form
req, err := http.NewRequestWithContext(ctx, form.Method, actionURL, strings.NewReader(formData.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "OneTerm-WebProxy/1.0")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Check if authentication was successful
// Usually a successful login redirects or returns 200 with cookies
success := resp.StatusCode >= 200 && resp.StatusCode < 400 && len(resp.Cookies()) > 0
return &WebAuthResult{
Success: success,
Message: fmt.Sprintf("Form auth %s", map[bool]string{true: "succeeded", false: "failed"}[success]),
Cookies: resp.Cookies(),
RedirectURL: resp.Header.Get("Location"),
}, nil
}
// APILoginAuthStrategy implements API-based authentication
type APILoginAuthStrategy struct{}
func (s *APILoginAuthStrategy) Name() string { return "api_login" }
func (s *APILoginAuthStrategy) Priority() int { return 8 }
func (s *APILoginAuthStrategy) CanHandle(ctx context.Context, siteInfo *WebSiteInfo) bool {
// Check for common API login endpoints
commonEndpoints := []string{"/api/login", "/auth/login", "/login", "/signin"}
baseURL, err := url.Parse(siteInfo.URL)
if err != nil {
return false
}
client := &http.Client{Timeout: 5 * time.Second}
for _, endpoint := range commonEndpoints {
testURL := baseURL.Scheme + "://" + baseURL.Host + endpoint
resp, err := client.Head(testURL)
if err == nil && resp.StatusCode != 404 {
resp.Body.Close()
return true
}
}
return false
}
func (s *APILoginAuthStrategy) Authenticate(ctx context.Context, credentials *WebCredentials, siteInfo *WebSiteInfo) (*WebAuthResult, error) {
baseURL, err := url.Parse(siteInfo.URL)
if err != nil {
return nil, err
}
// Try common API login endpoints
commonEndpoints := []string{"/api/login", "/auth/login", "/login", "/signin"}
client := &http.Client{Timeout: 30 * time.Second}
for _, endpoint := range commonEndpoints {
loginURL := baseURL.Scheme + "://" + baseURL.Host + endpoint
// Prepare JSON payload
payload := map[string]string{
"username": credentials.Username,
"password": credentials.Password,
}
jsonData, err := json.Marshal(payload)
if err != nil {
continue
}
req, err := http.NewRequestWithContext(ctx, "POST", loginURL, bytes.NewReader(jsonData))
if err != nil {
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "OneTerm-WebProxy/1.0")
resp, err := client.Do(req)
if err != nil {
continue
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
defer resp.Body.Close()
return &WebAuthResult{
Success: true,
Message: "API login succeeded",
Cookies: resp.Cookies(),
}, nil
}
resp.Body.Close()
}
return &WebAuthResult{
Success: false,
Message: "API login failed - no valid endpoint found",
}, nil
}