mirror of
https://github.com/veops/oneterm.git
synced 2025-10-16 12:21:06 +08:00
498 lines
14 KiB
Go
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
|
|
}
|