Files
sing-box-extended-mirror/transport/call/bitrix/auth.go
T

239 lines
6.9 KiB
Go

package bitrix
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/sagernet/sing-box/transport/call/common"
headless "github.com/kulikov0/headless-client"
)
const authNetBase = "https://auth2.bitrix24.net"
var (
reFlowCfg = regexp.MustCompile(`b24network\.security\.flowtoken"[^>]*>(\{[^<]*\})</script>`)
reJunk = regexp.MustCompile(`[!#$%@~]`)
reJSRedirect = regexp.MustCompile(`window\.location\.href\s*=\s*['"]([^'"]+)['"]`)
)
func (c *Client) SetCredentials(email, password string) {
c.email = strings.TrimSpace(email)
c.password = password
}
func (c *Client) loginWithPassword(email, password string) error {
if email == "" || password == "" {
return fmt.Errorf("login: no email/password")
}
if err := c.resetJar(); err != nil {
return err
}
flowToken, sessid, currentURI, err := c.bootstrapLogin()
if err != nil {
return err
}
c.Logger.Debug(fmt.Sprintf("[auth] bootstrap: flowToken=%dB sessid=%v authURL=%v", len(flowToken), sessid != "", strings.Contains(currentURI, "auth2.bitrix24.net")))
if err := c.authCheckLogin(flowToken, email, sessid, currentURI); err != nil {
return err
}
c.Logger.Debug("[auth] checkLogin OK")
if err := c.authCheck(flowToken, email, password, sessid); err != nil {
return err
}
c.Logger.Debug("[auth] check OK")
c.email = email
c.password = password
return c.completeOAuth()
}
func (c *Client) bootstrapLogin() (flowToken, sessid, currentURI string, err error) {
req, err := http.NewRequest("GET", c.portal+"/", nil)
if err != nil {
return "", "", "", err
}
req.Header = headless.ChromeWindows.Headers(headless.DestDocument)
if c.userAgent != "" {
req.Header.Set("User-Agent", c.userAgent)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return "", "", "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
page := string(body)
currentURI = resp.Request.URL.String()
if !strings.Contains(currentURI, "auth2.bitrix24.net") {
return "", "", "", fmt.Errorf("portal did not redirect to auth2 (landed on %s)", currentURI)
}
flowToken = computeFlowToken(page)
if m := reSessid.FindStringSubmatch(page); m != nil {
sessid = m[1]
}
return flowToken, sessid, currentURI, nil
}
func computeFlowToken(page string) string {
m := reFlowCfg.FindStringSubmatch(page)
if m == nil {
return ""
}
var cfg struct {
FTV string `json:"ftv"`
}
if err := json.Unmarshal([]byte(m[1]), &cfg); err != nil {
return ""
}
return reJunk.ReplaceAllString(cfg.FTV, "")
}
func (c *Client) authCheckLogin(flowToken, login, sessid, currentURI string) error {
form := url.Values{}
form.Set("flow-token-unique-id", flowToken)
form.Set("login", login)
form.Set("currentUri", currentURI)
form.Set("checkSocserv", "Y")
body, status, err := c.authDo("b24network.authorize.checkLogin", form, sessid)
if err != nil {
return err
}
var out struct {
Status string `json:"status"`
Data []struct {
HasAccess bool `json:"HAS_ACCESS"`
Captcha any `json:"CAPTCHA"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(body, &out); err != nil {
return fmt.Errorf("checkLogin: %w (status %d, body %s)", err, status, common.BodySnippet(body))
}
if out.Status != "success" || len(out.Data) == 0 {
return fmt.Errorf("login rejected: checkLogin (status %d, errors %+v, body %s)", status, out.Errors, common.BodySnippet(body))
}
if out.Data[0].Captcha != nil {
return fmt.Errorf("login rejected: captcha required at login step")
}
if !out.Data[0].HasAccess {
return fmt.Errorf("login rejected: account has no access")
}
return nil
}
func (c *Client) authCheck(flowToken, login, password, sessid string) error {
form := url.Values{}
form.Set("flow-token-unique-id", flowToken)
form.Set("login", login)
form.Set("password", password)
form.Set("remember", "Y")
body, status, err := c.authDo("b24network.authorize.check", form, sessid)
if err != nil {
return err
}
var out struct {
Status string `json:"status"`
Errors []struct {
Message string `json:"message"`
Code string `json:"code"`
} `json:"errors"`
}
if err := json.Unmarshal(body, &out); err != nil {
return fmt.Errorf("check: %w (status %d, body %s)", err, status, common.BodySnippet(body))
}
if out.Status != "success" || len(out.Errors) > 0 {
return fmt.Errorf("login rejected: password check (status %d, errors %+v, body %s)", status, out.Errors, common.BodySnippet(body))
}
return nil
}
func (c *Client) authDo(action string, form url.Values, sessid string) ([]byte, int, error) {
endpoint := authNetBase + "/bitrix/services/main/ajax.php?action=" + action
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, 0, err
}
req.Header = headless.ChromeWindows.Headers(headless.DestEmpty)
if c.userAgent != "" {
req.Header.Set("User-Agent", c.userAgent)
}
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Set("Origin", authNetBase)
req.Header.Set("Referer", authNetBase+"/authorization/")
req.Header.Set("Sec-Fetch-Site", secFetchSiteFor(endpoint, authNetBase))
req.Header.Set("X-Requested-With", "XMLHttpRequest")
if sessid != "" {
req.Header.Set("X-Bitrix-Csrf-Token", sessid)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
return body, resp.StatusCode, err
}
func (c *Client) getPage(target string) (body, final string, err error) {
req, err := http.NewRequest("GET", target, nil)
if err != nil {
return "", "", err
}
req.Header = headless.ChromeWindows.Headers(headless.DestDocument)
if c.userAgent != "" {
req.Header.Set("User-Agent", c.userAgent)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
return string(raw), resp.Request.URL.String(), nil
}
func (c *Client) completeOAuth() error {
target := c.portal + "/"
for hop := range 8 {
body, final, err := c.getPage(target)
if err != nil {
return err
}
c.Logger.Debug(fmt.Sprintf("[auth] oauth hop %d: final=%s len=%d", hop, final, len(body)))
if next := reJSRedirect.FindStringSubmatch(body); next != nil {
target = next[1]
continue
}
if strings.HasPrefix(final, c.portal) {
if m := reSessid.FindStringSubmatch(body); m != nil {
c.sessid = m[1]
return nil
}
}
target = c.portal + "/"
body, final, err = c.getPage(target)
if err != nil {
return err
}
if !strings.HasPrefix(final, c.portal) {
return fmt.Errorf("oauth handoff landed outside the portal, final=%s", final)
}
if m := reSessid.FindStringSubmatch(body); m != nil {
c.sessid = m[1]
return nil
}
c.sessid = ""
c.Logger.Debug("[auth] no csrf token on the portal page, it will be taken from the first response")
return nil
}
return fmt.Errorf("oauth handoff did not converge")
}