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 stringOrNumber `json:"nonce"` Ssecurity string `json:"ssecurity"` PassToken string `json:"passToken"` UserID stringOrNumber `json:"userId"` CUserID stringOrNumber `json:"cUserId"` } type stringOrNumber string func (value *stringOrNumber) UnmarshalJSON(payload []byte) error { decoded, err := decodeStringOrNumber(payload) if err != nil { return err } *value = stringOrNumber(decoded) return nil } 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 = string(data.Nonce) candidate.Ssecurity = data.Ssecurity candidate.PassToken = data.PassToken candidate.UserID = string(data.UserID) candidate.CUserID = string(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 }