feat: add Go API library
This commit is contained in:
@@ -0,0 +1,520 @@
|
||||
package mijia
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
"rsc.io/qr"
|
||||
)
|
||||
|
||||
const qrLoginTimeout = 120 * time.Second
|
||||
|
||||
type AuthData struct {
|
||||
UA string `json:"ua"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
PassO string `json:"pass_o"`
|
||||
Psecurity string `json:"psecurity"`
|
||||
Nonce string `json:"nonce"`
|
||||
Ssecurity string `json:"ssecurity"`
|
||||
PassToken string `json:"passToken"`
|
||||
UserID string `json:"userId"`
|
||||
CUserID string `json:"cUserId"`
|
||||
ServiceToken string `json:"serviceToken"`
|
||||
YetAnotherServiceToken string `json:"yetAnotherServiceToken"`
|
||||
ExpireTime int64 `json:"expireTime"`
|
||||
SaveTime int64 `json:"saveTime"`
|
||||
Extra map[string]string `json:"-"`
|
||||
}
|
||||
|
||||
func (data AuthData) MarshalJSON() ([]byte, error) {
|
||||
fields := map[string]any{
|
||||
"ua": data.UA, "deviceId": data.DeviceID, "pass_o": data.PassO,
|
||||
"psecurity": data.Psecurity, "nonce": data.Nonce, "ssecurity": data.Ssecurity,
|
||||
"passToken": data.PassToken, "userId": data.UserID, "cUserId": data.CUserID,
|
||||
"serviceToken": data.ServiceToken, "yetAnotherServiceToken": data.YetAnotherServiceToken,
|
||||
"expireTime": data.ExpireTime, "saveTime": data.SaveTime,
|
||||
}
|
||||
for key, value := range data.Extra {
|
||||
if _, stable := fields[key]; !stable {
|
||||
fields[key] = value
|
||||
}
|
||||
}
|
||||
return json.Marshal(fields)
|
||||
}
|
||||
|
||||
func (data *AuthData) UnmarshalJSON(payload []byte) error {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(payload, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
stable := map[string]any{
|
||||
"ua": &data.UA, "deviceId": &data.DeviceID, "pass_o": &data.PassO,
|
||||
"psecurity": &data.Psecurity, "nonce": &data.Nonce, "ssecurity": &data.Ssecurity,
|
||||
"passToken": &data.PassToken, "userId": &data.UserID, "cUserId": &data.CUserID,
|
||||
"serviceToken": &data.ServiceToken, "yetAnotherServiceToken": &data.YetAnotherServiceToken,
|
||||
"expireTime": &data.ExpireTime, "saveTime": &data.SaveTime,
|
||||
}
|
||||
data.Extra = make(map[string]string)
|
||||
for key, raw := range fields {
|
||||
if target, ok := stable[key]; ok {
|
||||
if err := json.Unmarshal(raw, target); err != nil {
|
||||
return fmt.Errorf("decode auth field %s: %w", key, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var value string
|
||||
if json.Unmarshal(raw, &value) == nil {
|
||||
data.Extra[key] = value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (data AuthData) complete() bool {
|
||||
return data.UA != "" && data.Ssecurity != "" && data.UserID != "" && data.CUserID != "" && data.ServiceToken != ""
|
||||
}
|
||||
|
||||
func (data AuthData) yetAnotherServiceToken() string {
|
||||
if data.YetAnotherServiceToken != "" {
|
||||
return data.YetAnotherServiceToken
|
||||
}
|
||||
if data.Extra != nil && data.Extra["yetAnotherServiceToken"] != "" {
|
||||
return data.Extra["yetAnotherServiceToken"]
|
||||
}
|
||||
return data.ServiceToken
|
||||
}
|
||||
|
||||
func (data AuthData) clone() AuthData {
|
||||
clone := data
|
||||
if data.Extra != nil {
|
||||
clone.Extra = make(map[string]string, len(data.Extra))
|
||||
for key, value := range data.Extra {
|
||||
clone.Extra[key] = value
|
||||
}
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
// AuthData returns a snapshot of the client's current authentication data.
|
||||
func (client *Client) AuthData() AuthData {
|
||||
client.authMu.RLock()
|
||||
defer client.authMu.RUnlock()
|
||||
return client.authData.clone()
|
||||
}
|
||||
|
||||
func (client *Client) setAuthData(authData AuthData) {
|
||||
client.authMu.Lock()
|
||||
client.authData = authData.clone()
|
||||
client.authMu.Unlock()
|
||||
}
|
||||
|
||||
func (client *Client) updateAuthData(update func(*AuthData)) AuthData {
|
||||
client.authMu.Lock()
|
||||
defer client.authMu.Unlock()
|
||||
update(&client.authData)
|
||||
return client.authData.clone()
|
||||
}
|
||||
|
||||
func (client *Client) loadAuthData() error {
|
||||
payload, err := os.ReadFile(client.authPath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read auth data: %w", err)
|
||||
}
|
||||
var authData AuthData
|
||||
if err := json.Unmarshal(payload, &authData); err != nil {
|
||||
return fmt.Errorf("decode auth data: %w", err)
|
||||
}
|
||||
client.setAuthData(authData)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *Client) ensureIdentity() {
|
||||
client.updateAuthData(func(authData *AuthData) {
|
||||
if authData.PassO == "" {
|
||||
authData.PassO = randomString(16, "0123456789abcdef")
|
||||
}
|
||||
if authData.DeviceID == "" {
|
||||
authData.DeviceID = randomString(16, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-")
|
||||
}
|
||||
if authData.UA != "" {
|
||||
return
|
||||
}
|
||||
countryCode := "CN"
|
||||
if parts := strings.Split(client.locale, "_"); len(parts) == 2 {
|
||||
countryCode = parts[1]
|
||||
}
|
||||
id1 := randomString(40, "0123456789ABCDEF")
|
||||
id2 := randomString(32, "0123456789ABCDEF")
|
||||
id3 := randomString(32, "0123456789ABCDEF")
|
||||
id4 := randomString(40, "0123456789ABCDEF")
|
||||
authData.UA = fmt.Sprintf("Android-15-11.0.701-Xiaomi-23046RP50C-OS2.0.212.0.VMYCNXM-%s-%s-%s-%s-SmartHome-MI_APP_STORE-%s|%s|%s-64", id1, countryCode, id3, id2, id1, id4, authData.PassO)
|
||||
})
|
||||
}
|
||||
|
||||
func randomString(length int, alphabet string) string {
|
||||
random := make([]byte, length)
|
||||
if _, err := rand.Read(random); err != nil {
|
||||
panic(fmt.Sprintf("generate random identity: %v", err))
|
||||
}
|
||||
for index := range random {
|
||||
random[index] = alphabet[int(random[index])%len(alphabet)]
|
||||
}
|
||||
return string(random)
|
||||
}
|
||||
|
||||
func (client *Client) saveAuthData() error {
|
||||
authData := client.updateAuthData(func(authData *AuthData) {
|
||||
authData.SaveTime = time.Now().UnixMilli()
|
||||
})
|
||||
payload, err := json.MarshalIndent(authData, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode auth data: %w", err)
|
||||
}
|
||||
directory := filepath.Dir(client.authPath)
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return fmt.Errorf("create auth directory: %w", err)
|
||||
}
|
||||
temporary, err := os.CreateTemp(directory, ".auth-*.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary auth file: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if err := temporary.Chmod(0o600); err != nil {
|
||||
temporary.Close()
|
||||
return fmt.Errorf("secure temporary auth file: %w", err)
|
||||
}
|
||||
if _, err := temporary.Write(payload); err != nil {
|
||||
temporary.Close()
|
||||
return fmt.Errorf("write auth data: %w", err)
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
temporary.Close()
|
||||
return fmt.Errorf("sync auth data: %w", err)
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return fmt.Errorf("close auth data: %w", err)
|
||||
}
|
||||
if err := os.Rename(temporaryPath, client.authPath); err != nil {
|
||||
return fmt.Errorf("replace auth data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseServiceResponse(payload []byte, target any) error {
|
||||
payload = []byte(strings.TrimPrefix(string(payload), "&&&START&&&"))
|
||||
if err := json.Unmarshal(payload, target); err != nil {
|
||||
return fmt.Errorf("decode login response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type serviceLoginData struct {
|
||||
Code int `json:"code"`
|
||||
Desc string `json:"desc"`
|
||||
Location string `json:"location"`
|
||||
Ssecurity string `json:"ssecurity"`
|
||||
}
|
||||
|
||||
type qrLoginData struct {
|
||||
Code int `json:"code"`
|
||||
Desc string `json:"desc"`
|
||||
LoginURL string `json:"loginUrl"`
|
||||
QR string `json:"qr"`
|
||||
LP string `json:"lp"`
|
||||
}
|
||||
|
||||
type longPollData struct {
|
||||
Code int `json:"code"`
|
||||
Desc string `json:"desc"`
|
||||
Location string `json:"location"`
|
||||
Psecurity string `json:"psecurity"`
|
||||
Nonce string `json:"nonce"`
|
||||
Ssecurity string `json:"ssecurity"`
|
||||
PassToken string `json:"passToken"`
|
||||
UserID string `json:"userId"`
|
||||
CUserID string `json:"cUserId"`
|
||||
}
|
||||
|
||||
func (client *Client) Login(ctx context.Context) (AuthData, error) {
|
||||
client.loginMu.Lock()
|
||||
defer client.loginMu.Unlock()
|
||||
|
||||
location, refreshed, err := client.getLocation(ctx)
|
||||
if err != nil {
|
||||
return AuthData{}, err
|
||||
}
|
||||
if refreshed {
|
||||
if err := client.saveAuthData(); err != nil {
|
||||
return AuthData{}, err
|
||||
}
|
||||
return client.AuthData(), nil
|
||||
}
|
||||
loginData, err := client.getQRLoginData(ctx, location)
|
||||
if err != nil {
|
||||
return AuthData{}, err
|
||||
}
|
||||
if client.qrWriter != nil {
|
||||
if _, err := qr.Encode(loginData.LoginURL, qr.L); err != nil {
|
||||
return AuthData{}, fmt.Errorf("encode login QR code: %w", err)
|
||||
}
|
||||
fmt.Fprintf(client.qrWriter, "请使用米家APP扫描下方二维码\n%s\n", loginData.LoginURL)
|
||||
qrterminal.GenerateHalfBlock(loginData.LoginURL, qrterminal.L, client.qrWriter)
|
||||
if loginData.QR != "" {
|
||||
fmt.Fprintf(client.qrWriter, "二维码图片: %s\n", loginData.QR)
|
||||
}
|
||||
}
|
||||
return client.completeQRLogin(ctx, loginData)
|
||||
}
|
||||
|
||||
func (client *Client) getLocation(ctx context.Context) (url.Values, bool, error) {
|
||||
httpClient := client.newSession()
|
||||
serviceURL, err := url.Parse(client.serviceLoginURL)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("parse service login URL: %w", err)
|
||||
}
|
||||
query := serviceURL.Query()
|
||||
query.Set("_json", "true")
|
||||
query.Set("sid", "mijia")
|
||||
query.Set("_locale", client.locale)
|
||||
serviceURL.RawQuery = query.Encode()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, serviceURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
client.setLoginHeaders(request, true)
|
||||
var data serviceLoginData
|
||||
if err := client.doLoginRequestWithClient(httpClient, request, false, &data); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if data.Location == "" {
|
||||
return nil, false, &LoginError{Code: data.Code, Message: "登录响应缺少 location"}
|
||||
}
|
||||
if data.Code == 0 {
|
||||
refreshRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, data.Location, nil)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
client.setLoginHeaders(refreshRequest, false)
|
||||
response, err := httpClient.Do(refreshRequest)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("refresh login token: %w", err)
|
||||
}
|
||||
body, readErr := readHTTPResponse(response)
|
||||
response.Body.Close()
|
||||
if readErr != nil {
|
||||
return nil, false, fmt.Errorf("read token refresh response: %w", readErr)
|
||||
}
|
||||
if response.StatusCode == http.StatusOK && string(body) == "ok" {
|
||||
candidate := client.AuthData()
|
||||
serviceTokenReceived := updateAuthDataFromCookies(&candidate, httpClient, response.Request.URL)
|
||||
candidate.Ssecurity = data.Ssecurity
|
||||
if !serviceTokenReceived || !candidate.complete() {
|
||||
return nil, false, &LoginError{Code: -1, Message: "刷新Token响应认证信息不完整"}
|
||||
}
|
||||
candidate.ExpireTime = time.Now().Add(30 * 24 * time.Hour).UnixMilli()
|
||||
client.setAuthData(candidate)
|
||||
return nil, true, nil
|
||||
}
|
||||
}
|
||||
locationURL, err := url.Parse(data.Location)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("parse login location: %w", err)
|
||||
}
|
||||
return locationURL.Query(), false, nil
|
||||
}
|
||||
|
||||
func (client *Client) getQRLoginData(ctx context.Context, location url.Values) (qrLoginData, error) {
|
||||
location.Set("theme", "")
|
||||
location.Set("bizDeviceType", "")
|
||||
location.Set("_hasLogo", "false")
|
||||
location.Set("_qrsize", "240")
|
||||
location.Set("_dc", fmt.Sprintf("%d", time.Now().UnixMilli()))
|
||||
loginURL, err := url.Parse(client.loginURL)
|
||||
if err != nil {
|
||||
return qrLoginData{}, err
|
||||
}
|
||||
loginURL.RawQuery = location.Encode()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, loginURL.String(), nil)
|
||||
if err != nil {
|
||||
return qrLoginData{}, err
|
||||
}
|
||||
client.setLoginHeaders(request, false)
|
||||
var data qrLoginData
|
||||
if err := client.doLoginRequest(request, true, &data); err != nil {
|
||||
return qrLoginData{}, err
|
||||
}
|
||||
if data.LoginURL == "" || data.LP == "" {
|
||||
return qrLoginData{}, &LoginError{Code: data.Code, Message: "二维码登录响应不完整"}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (client *Client) completeQRLogin(ctx context.Context, loginData qrLoginData) (AuthData, error) {
|
||||
pollContext, cancel := context.WithTimeout(ctx, qrLoginTimeout)
|
||||
defer cancel()
|
||||
httpClient := client.newSession()
|
||||
request, err := http.NewRequestWithContext(pollContext, http.MethodGet, loginData.LP, nil)
|
||||
if err != nil {
|
||||
return AuthData{}, err
|
||||
}
|
||||
client.setLoginHeaders(request, false)
|
||||
var data longPollData
|
||||
if err := client.doLoginRequestWithClient(httpClient, request, true, &data); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return AuthData{}, &LoginError{Code: -1, Message: "超时,请重试"}
|
||||
}
|
||||
return AuthData{}, err
|
||||
}
|
||||
callback, err := http.NewRequestWithContext(ctx, http.MethodGet, data.Location, nil)
|
||||
if err != nil {
|
||||
return AuthData{}, err
|
||||
}
|
||||
client.setLoginHeaders(callback, false)
|
||||
response, err := httpClient.Do(callback)
|
||||
if err != nil {
|
||||
return AuthData{}, fmt.Errorf("complete login callback: %w", err)
|
||||
}
|
||||
_, readErr := readHTTPResponse(response)
|
||||
response.Body.Close()
|
||||
if readErr != nil {
|
||||
return AuthData{}, fmt.Errorf("read login callback response: %w", readErr)
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return AuthData{}, &LoginError{Code: response.StatusCode, Message: "登录回调失败"}
|
||||
}
|
||||
candidate := client.AuthData()
|
||||
candidate.Ssecurity = ""
|
||||
candidate.UserID = ""
|
||||
candidate.CUserID = ""
|
||||
candidate.ServiceToken = ""
|
||||
serviceTokenReceived := updateAuthDataFromCookies(&candidate, httpClient, response.Request.URL)
|
||||
candidate.Psecurity = data.Psecurity
|
||||
candidate.Nonce = data.Nonce
|
||||
candidate.Ssecurity = data.Ssecurity
|
||||
candidate.PassToken = data.PassToken
|
||||
candidate.UserID = data.UserID
|
||||
candidate.CUserID = data.CUserID
|
||||
if !serviceTokenReceived || !candidate.complete() {
|
||||
return AuthData{}, &LoginError{Code: -1, Message: "登录回调认证信息不完整"}
|
||||
}
|
||||
candidate.ExpireTime = time.Now().Add(30 * 24 * time.Hour).UnixMilli()
|
||||
client.setAuthData(candidate)
|
||||
if err := client.saveAuthData(); err != nil {
|
||||
return AuthData{}, err
|
||||
}
|
||||
return client.AuthData(), nil
|
||||
}
|
||||
|
||||
func (client *Client) doLoginRequest(request *http.Request, verifyCode bool, target any) error {
|
||||
return client.doLoginRequestWithClient(client.session(), request, verifyCode, target)
|
||||
}
|
||||
|
||||
func (client *Client) doLoginRequestWithClient(httpClient *http.Client, request *http.Request, verifyCode bool, target any) error {
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send login request: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := readHTTPResponse(response)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read login response: %w", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return &LoginError{Code: response.StatusCode, Message: string(body)}
|
||||
}
|
||||
if err := parseServiceResponse(body, target); err != nil {
|
||||
return err
|
||||
}
|
||||
if verifyCode {
|
||||
encoded, _ := json.Marshal(target)
|
||||
var status struct {
|
||||
Code int `json:"code"`
|
||||
Desc string `json:"desc"`
|
||||
}
|
||||
_ = json.Unmarshal(encoded, &status)
|
||||
if status.Code != 0 {
|
||||
return &LoginError{Code: status.Code, Message: status.Desc}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *Client) setLoginHeaders(request *http.Request, withCookies bool) {
|
||||
authData := client.AuthData()
|
||||
request.Header.Set("User-Agent", authData.UA)
|
||||
request.Header.Set("Connection", "keep-alive")
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if withCookies {
|
||||
request.Header.Set("Cookie", strings.Join([]string{
|
||||
"deviceId=" + authData.DeviceID,
|
||||
"pass_o=" + authData.PassO,
|
||||
"passToken=" + authData.PassToken,
|
||||
"userId=" + authData.UserID,
|
||||
"cUserId=" + authData.CUserID,
|
||||
"uLocale=" + client.locale,
|
||||
}, ";"))
|
||||
}
|
||||
}
|
||||
|
||||
func updateAuthDataFromCookies(authData *AuthData, httpClient *http.Client, target *url.URL) bool {
|
||||
if httpClient.Jar == nil || target == nil {
|
||||
return false
|
||||
}
|
||||
cookies := httpClient.Jar.Cookies(target)
|
||||
if authData.Extra == nil {
|
||||
authData.Extra = make(map[string]string)
|
||||
}
|
||||
serviceTokenReceived := false
|
||||
for _, cookie := range cookies {
|
||||
switch cookie.Name {
|
||||
case "serviceToken":
|
||||
if cookie.Value != "" {
|
||||
authData.ServiceToken = cookie.Value
|
||||
serviceTokenReceived = true
|
||||
}
|
||||
case "yetAnotherServiceToken":
|
||||
authData.YetAnotherServiceToken = cookie.Value
|
||||
case "cUserId":
|
||||
authData.CUserID = cookie.Value
|
||||
default:
|
||||
authData.Extra[cookie.Name] = cookie.Value
|
||||
}
|
||||
}
|
||||
return serviceTokenReceived
|
||||
}
|
||||
|
||||
func (client *Client) refreshToken(ctx context.Context) error {
|
||||
client.loginMu.Lock()
|
||||
defer client.loginMu.Unlock()
|
||||
|
||||
available, _ := client.Available(ctx)
|
||||
if available {
|
||||
return nil
|
||||
}
|
||||
_, refreshed, err := client.getLocation(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !refreshed {
|
||||
return &LoginError{Code: -1, Message: "刷新Token失败,请重新登录"}
|
||||
}
|
||||
if err := client.saveAuthData(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user