210 lines
5.3 KiB
Go
210 lines
5.3 KiB
Go
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
|
|
}
|