403 lines
10 KiB
Go
403 lines
10 KiB
Go
package shunyunbao
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"cmroubao/backend-api/internal/domain"
|
|
)
|
|
|
|
const (
|
|
defaultSessionTimeout = 30 * time.Second
|
|
defaultCaptchaTTL = 5 * time.Minute
|
|
maxCaptchaBytes = 2 << 20
|
|
maxERPResponseBytes = 4 << 20
|
|
)
|
|
|
|
var (
|
|
ErrCaptchaTicketInvalid = errors.New("ERP captcha ticket is invalid")
|
|
ErrLoginRejected = errors.New("ERP login was rejected")
|
|
)
|
|
|
|
type SessionConfig struct {
|
|
BaseURL string
|
|
Username string
|
|
Password string
|
|
Timeout time.Duration
|
|
CaptchaTTL time.Duration
|
|
AllowInsecureHTTP bool // Used only by isolated httptest contracts.
|
|
}
|
|
|
|
type SessionStatus struct {
|
|
Configured bool
|
|
Authenticated bool
|
|
CaptchaReady bool
|
|
CaptchaTicket string
|
|
}
|
|
|
|
type CaptchaImage struct {
|
|
Content []byte
|
|
ContentType string
|
|
}
|
|
|
|
type SessionManager struct {
|
|
mu sync.Mutex
|
|
baseURL string
|
|
username string
|
|
password string
|
|
timeout time.Duration
|
|
captchaTTL time.Duration
|
|
headers http.Header
|
|
http *http.Client
|
|
authenticated bool
|
|
captchaTicket string
|
|
captchaContent []byte
|
|
captchaType string
|
|
captchaExpires time.Time
|
|
}
|
|
|
|
func NewSessionManager(config SessionConfig) (*SessionManager, error) {
|
|
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
|
|
headers, err := RequestHeaders(baseURL)
|
|
if err != nil {
|
|
return nil, errors.New("shunyunbao session configuration is invalid")
|
|
}
|
|
parsed, _ := url.Parse(baseURL)
|
|
if parsed.Scheme != "https" && !config.AllowInsecureHTTP {
|
|
return nil, errors.New("shunyunbao session requires HTTPS")
|
|
}
|
|
username := strings.TrimSpace(config.Username)
|
|
if (username == "") != (config.Password == "") ||
|
|
!utf8.ValidString(username) || hasControl(username) ||
|
|
strings.ContainsRune(config.Password, '\x00') {
|
|
return nil, errors.New("shunyunbao credentials are invalid")
|
|
}
|
|
timeout := config.Timeout
|
|
if timeout <= 0 {
|
|
timeout = defaultSessionTimeout
|
|
}
|
|
captchaTTL := config.CaptchaTTL
|
|
if captchaTTL <= 0 {
|
|
captchaTTL = defaultCaptchaTTL
|
|
}
|
|
jar, err := cookiejar.New(nil)
|
|
if err != nil {
|
|
return nil, errors.New("create shunyunbao cookie jar")
|
|
}
|
|
return &SessionManager{
|
|
baseURL: baseURL,
|
|
username: username,
|
|
password: config.Password,
|
|
timeout: timeout,
|
|
captchaTTL: captchaTTL,
|
|
headers: headers,
|
|
http: &http.Client{
|
|
Jar: jar,
|
|
Timeout: timeout,
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (manager *SessionManager) Status() SessionStatus {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
manager.expireCaptchaLocked(time.Now())
|
|
return manager.statusLocked()
|
|
}
|
|
|
|
func (manager *SessionManager) FetchCaptcha(
|
|
ctx context.Context,
|
|
) (SessionStatus, error) {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
if !manager.configuredLocked() {
|
|
return manager.statusLocked(), domain.ErrFreightSourceNotConfigured
|
|
}
|
|
request, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodGet,
|
|
manager.baseURL+CaptchaPath+"?_="+strconv.FormatInt(time.Now().UnixMilli(), 10),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
|
|
}
|
|
manager.applyHeaders(request)
|
|
response, err := manager.http.Do(request)
|
|
if err != nil {
|
|
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
return manager.statusLocked(), manager.responseErrorLocked(response.StatusCode)
|
|
}
|
|
contentType := strings.TrimSpace(
|
|
strings.Split(response.Header.Get("Content-Type"), ";")[0],
|
|
)
|
|
if !strings.HasPrefix(contentType, "image/") {
|
|
return manager.statusLocked(), domain.ErrFreightSourceProtocol
|
|
}
|
|
content, err := readBounded(response.Body, maxCaptchaBytes)
|
|
if err != nil || len(content) == 0 {
|
|
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
|
|
}
|
|
ticket, err := newCaptchaTicket()
|
|
if err != nil {
|
|
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
|
|
}
|
|
manager.captchaTicket = ticket
|
|
manager.captchaContent = content
|
|
manager.captchaType = contentType
|
|
manager.captchaExpires = time.Now().Add(manager.captchaTTL)
|
|
return manager.statusLocked(), nil
|
|
}
|
|
|
|
func (manager *SessionManager) OpenCaptcha(
|
|
ticket string,
|
|
) (CaptchaImage, error) {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
manager.expireCaptchaLocked(time.Now())
|
|
if !manager.captchaMatchesLocked(ticket) {
|
|
return CaptchaImage{}, ErrCaptchaTicketInvalid
|
|
}
|
|
return CaptchaImage{
|
|
Content: append([]byte(nil), manager.captchaContent...),
|
|
ContentType: manager.captchaType,
|
|
}, nil
|
|
}
|
|
|
|
func (manager *SessionManager) Login(
|
|
ctx context.Context,
|
|
ticket, captchaCode string,
|
|
) (SessionStatus, error) {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
if !manager.configuredLocked() {
|
|
return manager.statusLocked(), domain.ErrFreightSourceNotConfigured
|
|
}
|
|
manager.expireCaptchaLocked(time.Now())
|
|
if !manager.captchaMatchesLocked(ticket) || !validCaptchaCode(captchaCode) {
|
|
return manager.statusLocked(), ErrCaptchaTicketInvalid
|
|
}
|
|
defer manager.clearCaptchaLocked()
|
|
manager.authenticated = false
|
|
payload, err := json.Marshal(map[string]string{
|
|
"username": manager.username,
|
|
"password": manager.password,
|
|
"code": strings.TrimSpace(captchaCode),
|
|
})
|
|
if err != nil {
|
|
return manager.statusLocked(), domain.ErrFreightSourceProtocol
|
|
}
|
|
data, err := manager.requestJSONLocked(
|
|
ctx,
|
|
http.MethodPost,
|
|
LoginPath,
|
|
payload,
|
|
true,
|
|
)
|
|
if err != nil {
|
|
return manager.statusLocked(), err
|
|
}
|
|
if !hasUser(data) {
|
|
return manager.statusLocked(), domain.ErrFreightSourceProtocol
|
|
}
|
|
if _, err := manager.validateLocked(ctx); err != nil {
|
|
return manager.statusLocked(), err
|
|
}
|
|
manager.authenticated = true
|
|
manager.clearCaptchaLocked()
|
|
return manager.statusLocked(), nil
|
|
}
|
|
|
|
func (manager *SessionManager) Validate(
|
|
ctx context.Context,
|
|
) (SessionStatus, error) {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
if !manager.configuredLocked() {
|
|
return manager.statusLocked(), domain.ErrFreightSourceNotConfigured
|
|
}
|
|
if !manager.authenticated {
|
|
return manager.statusLocked(), domain.ErrFreightSourceSessionNeeded
|
|
}
|
|
if _, err := manager.validateLocked(ctx); err != nil {
|
|
return manager.statusLocked(), err
|
|
}
|
|
return manager.statusLocked(), nil
|
|
}
|
|
|
|
func (manager *SessionManager) validateLocked(ctx context.Context) (any, error) {
|
|
data, err := manager.requestJSONLocked(
|
|
ctx,
|
|
http.MethodGet,
|
|
UserPath,
|
|
nil,
|
|
false,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !hasUser(data) {
|
|
return nil, domain.ErrFreightSourceProtocol
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func (manager *SessionManager) requestJSONLocked(
|
|
ctx context.Context,
|
|
method, path string,
|
|
body []byte,
|
|
loginRequest bool,
|
|
) (any, error) {
|
|
var content io.Reader
|
|
if body != nil {
|
|
content = bytes.NewReader(body)
|
|
}
|
|
request, err := http.NewRequestWithContext(
|
|
ctx,
|
|
method,
|
|
manager.baseURL+path,
|
|
content,
|
|
)
|
|
if err != nil {
|
|
return nil, domain.ErrFreightSourceUnavailable
|
|
}
|
|
manager.applyHeaders(request)
|
|
if body != nil {
|
|
request.Header.Set("Content-Type", "application/json")
|
|
}
|
|
response, err := manager.http.Do(request)
|
|
if err != nil {
|
|
return nil, domain.ErrFreightSourceUnavailable
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
|
if loginRequest {
|
|
return nil, ErrLoginRejected
|
|
}
|
|
return nil, manager.responseErrorLocked(response.StatusCode)
|
|
}
|
|
contentBytes, err := readBounded(response.Body, maxERPResponseBytes)
|
|
if err != nil {
|
|
return nil, domain.ErrFreightSourceUnavailable
|
|
}
|
|
var envelope struct {
|
|
Status bool `json:"status"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(contentBytes))
|
|
if err := decoder.Decode(&envelope); err != nil || len(envelope.Data) == 0 {
|
|
return nil, domain.ErrFreightSourceProtocol
|
|
}
|
|
if !envelope.Status {
|
|
if loginRequest {
|
|
return nil, ErrLoginRejected
|
|
}
|
|
manager.authenticated = false
|
|
return nil, domain.ErrFreightSourceSessionNeeded
|
|
}
|
|
var data any
|
|
dataDecoder := json.NewDecoder(bytes.NewReader(envelope.Data))
|
|
dataDecoder.UseNumber()
|
|
if err := dataDecoder.Decode(&data); err != nil {
|
|
return nil, domain.ErrFreightSourceProtocol
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func (manager *SessionManager) applyHeaders(request *http.Request) {
|
|
for name, values := range manager.headers {
|
|
request.Header[name] = append([]string(nil), values...)
|
|
}
|
|
}
|
|
|
|
func (manager *SessionManager) responseErrorLocked(status int) error {
|
|
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
|
manager.authenticated = false
|
|
return domain.ErrFreightSourceSessionNeeded
|
|
}
|
|
return domain.ErrFreightSourceUnavailable
|
|
}
|
|
|
|
func (manager *SessionManager) configuredLocked() bool {
|
|
return manager.username != "" && manager.password != ""
|
|
}
|
|
|
|
func (manager *SessionManager) statusLocked() SessionStatus {
|
|
return SessionStatus{
|
|
Configured: manager.configuredLocked(),
|
|
Authenticated: manager.authenticated,
|
|
CaptchaReady: manager.captchaTicket != "",
|
|
CaptchaTicket: manager.captchaTicket,
|
|
}
|
|
}
|
|
|
|
func (manager *SessionManager) captchaMatchesLocked(ticket string) bool {
|
|
return ticket != "" && ticket == manager.captchaTicket
|
|
}
|
|
|
|
func (manager *SessionManager) expireCaptchaLocked(now time.Time) {
|
|
if manager.captchaTicket != "" && !now.Before(manager.captchaExpires) {
|
|
manager.clearCaptchaLocked()
|
|
}
|
|
}
|
|
|
|
func (manager *SessionManager) clearCaptchaLocked() {
|
|
manager.captchaTicket = ""
|
|
manager.captchaContent = nil
|
|
manager.captchaType = ""
|
|
manager.captchaExpires = time.Time{}
|
|
}
|
|
|
|
func hasUser(value any) bool {
|
|
data, ok := value.(map[string]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if user, exists := data["user"]; exists {
|
|
_, ok := user.(map[string]any)
|
|
return ok
|
|
}
|
|
return data["id"] != nil || data["username"] != nil
|
|
}
|
|
|
|
func validCaptchaCode(value string) bool {
|
|
value = strings.TrimSpace(value)
|
|
return value != "" && len([]byte(value)) <= 64 && utf8.ValidString(value) &&
|
|
!hasControl(value)
|
|
}
|
|
|
|
func newCaptchaTicket() (string, error) {
|
|
value := make([]byte, 32)
|
|
if _, err := io.ReadFull(rand.Reader, value); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(value), nil
|
|
}
|
|
|
|
func readBounded(reader io.Reader, maximum int64) ([]byte, error) {
|
|
content, err := io.ReadAll(io.LimitReader(reader, maximum+1))
|
|
if err != nil || int64(len(content)) > maximum {
|
|
return nil, errors.New("response exceeds limit")
|
|
}
|
|
return content, nil
|
|
}
|