feat(platform): harden telemetry pipeline and unify Semi UI workspaces
This commit is contained in:
209
go/vehicle-gateway/internal/feichibridge/login.go
Normal file
209
go/vehicle-gateway/internal/feichibridge/login.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package feichibridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const feichiPublicKey = `-----BEGIN PUBLIC KEY-----
|
||||
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDOwmFtEk1oJxDU0NI4kVO0Jx0X
|
||||
nt+Abx+JKGUzcRzPwjUkd5z9Ice5rh87CCmj0XjZ5pPac6TtA3f0v5FqiK/kjQY5
|
||||
XMLti4weJ4dcp1/q1O7PCYxRX8WgetGxwsjxGn+uoZOZkclN1PFS4wRnKEso6+G/
|
||||
e60QGB29cZoo4jZnZwIDAQAB
|
||||
-----END PUBLIC KEY-----`
|
||||
|
||||
type LoginCredentials struct {
|
||||
Username string
|
||||
Password string
|
||||
MaxAttempts int
|
||||
RetryDelay time.Duration
|
||||
}
|
||||
|
||||
type loginSession struct {
|
||||
credentials LoginCredentials
|
||||
solver CaptchaSolver
|
||||
mu sync.Mutex
|
||||
ready atomic.Bool
|
||||
csrf atomic.Value
|
||||
token atomic.Value
|
||||
}
|
||||
|
||||
type firstLoginEnvelope struct {
|
||||
Code any `json:"code"`
|
||||
}
|
||||
|
||||
type captchaPayload struct {
|
||||
Src string `json:"src"`
|
||||
}
|
||||
|
||||
type loginPayload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func newLoginSession(credentials LoginCredentials, solver CaptchaSolver) (*loginSession, error) {
|
||||
credentials.Username = strings.TrimSpace(credentials.Username)
|
||||
if credentials.Username == "" || credentials.Password == "" {
|
||||
return nil, errors.New("Feichi username and password are required")
|
||||
}
|
||||
if solver == nil {
|
||||
return nil, errors.New("captcha solver is required")
|
||||
}
|
||||
if credentials.MaxAttempts <= 0 {
|
||||
credentials.MaxAttempts = 20
|
||||
}
|
||||
if credentials.RetryDelay <= 0 {
|
||||
credentials.RetryDelay = time.Second
|
||||
}
|
||||
session := &loginSession{credentials: credentials, solver: solver}
|
||||
session.csrf.Store("")
|
||||
session.token.Store("")
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *loginSession) invalidate() {
|
||||
s.ready.Store(false)
|
||||
}
|
||||
|
||||
func (s *loginSession) cookieHeader() string {
|
||||
var cookies []string
|
||||
if csrf := s.csrf.Load().(string); csrf != "" {
|
||||
cookies = append(cookies, "CSRF="+csrf)
|
||||
}
|
||||
if token := s.token.Load().(string); token != "" {
|
||||
cookies = append(cookies, "R_SESS="+token)
|
||||
}
|
||||
return strings.Join(cookies, "; ")
|
||||
}
|
||||
|
||||
func (s *loginSession) csrfHeader() string {
|
||||
return s.csrf.Load().(string)
|
||||
}
|
||||
|
||||
func (s *loginSession) ensure(ctx context.Context, client *APIClient) error {
|
||||
if s.ready.Load() {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.ready.Load() {
|
||||
return nil
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= s.credentials.MaxAttempts; attempt++ {
|
||||
if err := s.loginAttempt(ctx, client); err == nil {
|
||||
s.ready.Store(true)
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
if attempt < s.credentials.MaxAttempts {
|
||||
timer := time.NewTimer(s.credentials.RetryDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("Feichi automatic login failed after %d attempts: %w", s.credentials.MaxAttempts, lastErr)
|
||||
}
|
||||
|
||||
func (s *loginSession) loginAttempt(ctx context.Context, client *APIClient) error {
|
||||
s.ready.Store(false)
|
||||
s.token.Store("")
|
||||
var first firstLoginEnvelope
|
||||
headers, err := client.rawJSON(ctx, http.MethodGet, "api/v1/first-login", nil, &first)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize login session: %w", err)
|
||||
}
|
||||
if !apiCodeOK(first.Code) {
|
||||
return apiCodeError(first.Code)
|
||||
}
|
||||
csrf := strings.TrimSpace(headers.Get("x-api-csrf"))
|
||||
if csrf == "" {
|
||||
for _, rawCookie := range headers.Values("Set-Cookie") {
|
||||
cookie, parseErr := http.ParseSetCookie(rawCookie)
|
||||
if parseErr == nil && cookie.Name == "CSRF" {
|
||||
csrf = cookie.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if csrf == "" {
|
||||
return errors.New("first-login response did not provide CSRF")
|
||||
}
|
||||
s.csrf.Store(csrf)
|
||||
|
||||
var captcha objectEnvelope[captchaPayload]
|
||||
if _, err := client.rawJSON(ctx, http.MethodGet, "api/v1/login/randCode", nil, &captcha); err != nil {
|
||||
return fmt.Errorf("request captcha: %w", err)
|
||||
}
|
||||
if !apiCodeOK(captcha.Code) {
|
||||
return apiCodeError(captcha.Code)
|
||||
}
|
||||
encodedImage := captcha.Data.Src
|
||||
if comma := strings.IndexByte(encodedImage, ','); comma >= 0 {
|
||||
encodedImage = encodedImage[comma+1:]
|
||||
}
|
||||
image, err := base64.StdEncoding.DecodeString(encodedImage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode captcha image: %w", err)
|
||||
}
|
||||
validCode, err := s.solver.Solve(ctx, image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encrypted, err := encryptFeichiPassword(s.credentials.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var login objectEnvelope[loginPayload]
|
||||
if _, err := client.rawJSON(ctx, http.MethodPost, "api/v1/login", map[string]string{
|
||||
"username": s.credentials.Username,
|
||||
"password": encrypted,
|
||||
"validCode": validCode,
|
||||
}, &login); err != nil {
|
||||
return fmt.Errorf("submit login: %w", err)
|
||||
}
|
||||
if !apiCodeOK(login.Code) {
|
||||
return apiCodeError(login.Code)
|
||||
}
|
||||
token := strings.TrimSpace(login.Data.Token)
|
||||
if token == "" {
|
||||
return errors.New("login succeeded without R_SESS token")
|
||||
}
|
||||
s.token.Store(token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func encryptFeichiPassword(password string) (string, error) {
|
||||
block, _ := pem.Decode([]byte(feichiPublicKey))
|
||||
if block == nil {
|
||||
return "", errors.New("decode Feichi RSA public key")
|
||||
}
|
||||
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse Feichi RSA public key: %w", err)
|
||||
}
|
||||
publicKey, ok := parsed.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return "", errors.New("Feichi public key is not RSA")
|
||||
}
|
||||
encrypted, err := rsa.EncryptPKCS1v15(rand.Reader, publicKey, []byte(password))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encrypt Feichi password: %w", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||
}
|
||||
Reference in New Issue
Block a user