257 lines
7.9 KiB
Go
257 lines
7.9 KiB
Go
package upstreamimpl
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
henanLelianLoginPath = "/api/v2/loginVerify"
|
|
henanLelianSharePath = "/api/v2/getShareToken"
|
|
henanLelianPlatformTTL = 23 * time.Hour
|
|
henanLelianShareInterval = time.Minute
|
|
)
|
|
|
|
// HenanLelianTokenConfig contains the public provider endpoints and the
|
|
// per-channel credentials required to obtain a shared OneLink token.
|
|
type HenanLelianTokenConfig struct {
|
|
BaseURL string
|
|
LoginPath string
|
|
Path string
|
|
UserName string
|
|
Password string
|
|
PasswayUID string
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// HenanLelianTokenProvider first obtains a platform access token and then
|
|
// exchanges it for the carrier-issued OneLink token.
|
|
type HenanLelianTokenProvider struct {
|
|
baseURL string
|
|
loginPath string
|
|
path string
|
|
userName string
|
|
password string
|
|
passwayUID string
|
|
client *http.Client
|
|
now func() time.Time
|
|
|
|
platformMu sync.Mutex
|
|
platformToken string
|
|
platformTokenExp time.Time
|
|
|
|
shareMu sync.Mutex
|
|
nextShareAt time.Time
|
|
shareInterval time.Duration
|
|
}
|
|
|
|
type henanLelianResponse struct {
|
|
Code json.RawMessage `json:"code"`
|
|
Message string `json:"message"`
|
|
Payload struct {
|
|
AccessToken string `json:"access_token"`
|
|
Token string `json:"token"`
|
|
} `json:"payload"`
|
|
}
|
|
|
|
func NewHenanLelianTokenProvider(config HenanLelianTokenConfig) *HenanLelianTokenProvider {
|
|
timeout := config.Timeout
|
|
if timeout <= 0 {
|
|
timeout = defaultUpstreamHTTPTimeout
|
|
}
|
|
return &HenanLelianTokenProvider{
|
|
baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"),
|
|
loginPath: normalizeProviderPath(config.LoginPath, henanLelianLoginPath),
|
|
path: normalizeProviderPath(config.Path, henanLelianSharePath),
|
|
userName: strings.TrimSpace(config.UserName),
|
|
password: config.Password,
|
|
passwayUID: strings.TrimSpace(config.PasswayUID),
|
|
client: &http.Client{Timeout: timeout},
|
|
now: time.Now,
|
|
shareInterval: henanLelianShareInterval,
|
|
}
|
|
}
|
|
|
|
func (p *HenanLelianTokenProvider) Fetch(ctx context.Context) (OneLinkToken, error) {
|
|
if p == nil || p.client == nil || p.now == nil {
|
|
return OneLinkToken{}, fmt.Errorf("Henan Lelian token provider is not initialized")
|
|
}
|
|
if p.baseURL == "" {
|
|
return OneLinkToken{}, fmt.Errorf("Henan Lelian token base URL is required")
|
|
}
|
|
if p.userName == "" || strings.TrimSpace(p.password) == "" {
|
|
return OneLinkToken{}, fmt.Errorf("Henan Lelian username and password are required")
|
|
}
|
|
if p.passwayUID == "" {
|
|
return OneLinkToken{}, fmt.Errorf("Henan Lelian passwayUid is required")
|
|
}
|
|
|
|
platformToken, err := p.getPlatformToken(ctx)
|
|
if err != nil {
|
|
return OneLinkToken{}, err
|
|
}
|
|
value, authFailed, err := p.fetchSharedToken(ctx, platformToken)
|
|
if authFailed {
|
|
p.clearPlatformToken(platformToken)
|
|
}
|
|
if err != nil {
|
|
return OneLinkToken{}, err
|
|
}
|
|
now := p.now()
|
|
return OneLinkToken{Value: value, CreatedAt: now}, nil
|
|
}
|
|
|
|
func (p *HenanLelianTokenProvider) getPlatformToken(ctx context.Context) (string, error) {
|
|
p.platformMu.Lock()
|
|
defer p.platformMu.Unlock()
|
|
|
|
now := p.now()
|
|
if p.platformToken != "" && now.Before(p.platformTokenExp) {
|
|
return p.platformToken, nil
|
|
}
|
|
body, err := json.Marshal(map[string]string{
|
|
"userName": p.userName,
|
|
"password": p.password,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("encode Henan Lelian login request: %w", err)
|
|
}
|
|
responseBody, status, err := p.postJSON(ctx, p.loginPath, body, "")
|
|
if err != nil {
|
|
return "", fmt.Errorf("request Henan Lelian login: %w", err)
|
|
}
|
|
if status < http.StatusOK || status >= http.StatusMultipleChoices {
|
|
return "", fmt.Errorf("Henan Lelian login HTTP status %d: %s", status, strings.TrimSpace(string(responseBody)))
|
|
}
|
|
var output henanLelianResponse
|
|
if err = json.Unmarshal(responseBody, &output); err != nil {
|
|
return "", fmt.Errorf("decode Henan Lelian login response: %w", err)
|
|
}
|
|
code, err := parseHenanLelianCode(output.Code)
|
|
if err != nil {
|
|
return "", fmt.Errorf("decode Henan Lelian login code: %w", err)
|
|
}
|
|
if code != "0" {
|
|
return "", fmt.Errorf("Henan Lelian login failed: code=%s, message=%s", code, output.Message)
|
|
}
|
|
token := strings.TrimSpace(output.Payload.AccessToken)
|
|
if token == "" {
|
|
return "", fmt.Errorf("Henan Lelian login access_token is empty")
|
|
}
|
|
p.platformToken = token
|
|
p.platformTokenExp = now.Add(henanLelianPlatformTTL)
|
|
return token, nil
|
|
}
|
|
|
|
func (p *HenanLelianTokenProvider) fetchSharedToken(ctx context.Context, platformToken string) (string, bool, error) {
|
|
if err := p.reserveShareAttempt(); err != nil {
|
|
return "", false, err
|
|
}
|
|
body, err := json.Marshal(map[string]string{"passwayUid": p.passwayUID})
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("encode Henan Lelian shared token request: %w", err)
|
|
}
|
|
responseBody, status, err := p.postJSON(ctx, p.path, body, platformToken)
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("request Henan Lelian shared token: %w", err)
|
|
}
|
|
if status < http.StatusOK || status >= http.StatusMultipleChoices {
|
|
authFailed := status == http.StatusUnauthorized || status == http.StatusForbidden
|
|
return "", authFailed, fmt.Errorf("Henan Lelian shared token HTTP status %d: %s", status, strings.TrimSpace(string(responseBody)))
|
|
}
|
|
var output henanLelianResponse
|
|
if err = json.Unmarshal(responseBody, &output); err != nil {
|
|
return "", false, fmt.Errorf("decode Henan Lelian shared token response: %w", err)
|
|
}
|
|
code, err := parseHenanLelianCode(output.Code)
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("decode Henan Lelian shared token code: %w", err)
|
|
}
|
|
if code != "0" {
|
|
authFailed := isHenanLelianAuthFailure(code, output.Message)
|
|
return "", authFailed, fmt.Errorf("Henan Lelian shared token failed: code=%s, message=%s", code, output.Message)
|
|
}
|
|
token := strings.TrimSpace(output.Payload.Token)
|
|
if token == "" {
|
|
return "", false, fmt.Errorf("Henan Lelian shared token is empty")
|
|
}
|
|
return token, false, nil
|
|
}
|
|
|
|
func (p *HenanLelianTokenProvider) reserveShareAttempt() error {
|
|
p.shareMu.Lock()
|
|
defer p.shareMu.Unlock()
|
|
|
|
now := p.now()
|
|
if now.Before(p.nextShareAt) {
|
|
return fmt.Errorf("Henan Lelian shared token is rate limited until %s", p.nextShareAt.Format(time.RFC3339))
|
|
}
|
|
p.nextShareAt = now.Add(p.shareInterval)
|
|
return nil
|
|
}
|
|
|
|
func (p *HenanLelianTokenProvider) clearPlatformToken(rejected string) {
|
|
p.platformMu.Lock()
|
|
if p.platformToken == rejected {
|
|
p.platformToken = ""
|
|
p.platformTokenExp = time.Time{}
|
|
}
|
|
p.platformMu.Unlock()
|
|
}
|
|
|
|
func (p *HenanLelianTokenProvider) postJSON(ctx context.Context, path string, body []byte, platformToken string) ([]byte, int, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinProviderURL(p.baseURL, path), bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if platformToken != "" {
|
|
req.Header.Set("access_token", platformToken)
|
|
}
|
|
resp, err := p.client.Do(req)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
responseBody, err := io.ReadAll(resp.Body)
|
|
return responseBody, resp.StatusCode, err
|
|
}
|
|
|
|
func parseHenanLelianCode(raw json.RawMessage) (string, error) {
|
|
var value string
|
|
if err := json.Unmarshal(raw, &value); err == nil {
|
|
return strings.TrimSpace(value), nil
|
|
}
|
|
var number json.Number
|
|
if err := json.Unmarshal(raw, &number); err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := strconv.ParseInt(number.String(), 10, 64); err != nil {
|
|
return "", err
|
|
}
|
|
return number.String(), nil
|
|
}
|
|
|
|
func isHenanLelianAuthFailure(code, message string) bool {
|
|
switch strings.TrimSpace(code) {
|
|
case "401", "40101", "40102", "403":
|
|
return true
|
|
}
|
|
message = strings.ToLower(strings.TrimSpace(message))
|
|
return strings.Contains(message, "access_token") ||
|
|
strings.Contains(message, "access token") ||
|
|
strings.Contains(message, "token expired") ||
|
|
strings.Contains(message, "token invalid") ||
|
|
strings.Contains(message, "鉴权") ||
|
|
strings.Contains(message, "认证失败")
|
|
}
|