|
package main |
|
|
|
import ( |
|
"bytes" |
|
"crypto/hmac" |
|
"crypto/rand" |
|
"crypto/sha256" |
|
"encoding/base64" |
|
"encoding/json" |
|
"flag" |
|
"fmt" |
|
"io" |
|
"log" |
|
"math/big" |
|
"net/http" |
|
"net/http/cookiejar" |
|
"net/http/httputil" |
|
"net/url" |
|
"strconv" |
|
"strings" |
|
"sync" |
|
"time" |
|
) |
|
|
|
const ( |
|
upstreamScheme = "https" |
|
upstreamHost = "api.io.mi.com" |
|
appPrefix = "/app" |
|
userAgent = "APP/com.xiaomi.mihome APPV/6.0.103 iosPassportSDK/3.9.0 iOS/14.4 miHSTS" |
|
) |
|
|
|
// 请求结构体,仅用于提取身份凭据,其余字段原样转发 |
|
type DeviceRequest struct { |
|
UserID string `json:"userId"` |
|
ServiceToken string `json:"serviceToken"` |
|
DeviceID string `json:"deviceId"` |
|
SecurityToken string `json:"securityToken"` |
|
Data json.RawMessage `json:"data"` |
|
// 其他字段不关心,保持兼容 |
|
} |
|
|
|
type ErrorResponse struct { |
|
Error string `json:"error"` |
|
Code int `json:"code"` |
|
} |
|
|
|
// CORS 中间件 |
|
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc { |
|
return func(w http.ResponseWriter, r *http.Request) { |
|
w.Header().Set("Access-Control-Allow-Origin", "*") |
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") |
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type") |
|
|
|
if r.Method == http.MethodOptions { |
|
w.WriteHeader(http.StatusOK) |
|
return |
|
} |
|
next(w, r) |
|
} |
|
} |
|
|
|
// 主处理函数:计算 signature 后将请求原样转发至 api.io.mi.com |
|
func handleRequest(w http.ResponseWriter, r *http.Request) { |
|
if r.Method != http.MethodPost { |
|
w.Header().Set("Content-Type", "text/plain; charset=UTF-8") |
|
w.WriteHeader(http.StatusMethodNotAllowed) |
|
w.Write([]byte("请使用POST方法")) |
|
return |
|
} |
|
|
|
// 读取原始请求体 |
|
bodyBytes, err := io.ReadAll(r.Body) |
|
if err != nil { |
|
sendJSONError(w, "读取请求体失败", http.StatusBadRequest) |
|
return |
|
} |
|
|
|
// 解析凭证信息 |
|
var devReq DeviceRequest |
|
if err := json.Unmarshal(bodyBytes, &devReq); err != nil { |
|
sendJSONError(w, fmt.Sprintf("无效的JSON格式: %v", err), http.StatusBadRequest) |
|
return |
|
} |
|
|
|
if devReq.UserID == "" || devReq.ServiceToken == "" || devReq.DeviceID == "" || devReq.SecurityToken == "" || len(devReq.Data) == 0 { |
|
sendJSONError(w, "缺少身份验证参数", http.StatusBadRequest) |
|
return |
|
} |
|
|
|
// 还原请求体,供 ReverseProxy 使用 |
|
r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) |
|
|
|
proxy := buildProxy(devReq) |
|
proxy.ServeHTTP(w, r) |
|
} |
|
|
|
// 构造反向代理,负责将请求转发到 api.io.mi.com 并在 Director 中注入签名。 |
|
func buildProxy(creds DeviceRequest) *httputil.ReverseProxy { |
|
director := func(req *http.Request) { |
|
// 生成 nonce / signedNonce / signature |
|
nonce := generateNonce() |
|
signedNonce, err := generateSignedNonce(creds.SecurityToken, nonce) |
|
if err != nil { |
|
// 发生错误时让 ErrorHandler 处理 |
|
req.URL = nil |
|
return |
|
} |
|
|
|
uriForSign := strings.TrimPrefix(req.URL.Path, appPrefix) |
|
data := string(creds.Data) |
|
signature, err := generateSignature(uriForSign, signedNonce, nonce, data) |
|
if err != nil { |
|
req.URL = nil |
|
return |
|
} |
|
|
|
params := url.Values{} |
|
params.Set("_nonce", nonce) |
|
params.Set("data", data) |
|
params.Set("signature", signature) |
|
|
|
// 修改请求为转发到上游 |
|
req.URL.Scheme = upstreamScheme |
|
req.URL.Host = upstreamHost |
|
req.Host = upstreamHost |
|
req.Method = http.MethodPost |
|
req.Body = io.NopCloser(strings.NewReader(params.Encode())) |
|
req.ContentLength = int64(len(params.Encode())) |
|
|
|
// 重置 Header |
|
req.Header = make(http.Header) |
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
|
req.Header.Set("User-Agent", userAgent) |
|
req.Header.Set("x-xiaomi-protocal-flag-cli", "PROTOCAL-HTTP2") |
|
cookie := fmt.Sprintf("serviceToken=%s; userId=%s; PassportDeviceId=%s", creds.ServiceToken, creds.UserID, creds.DeviceID) |
|
req.Header.Set("Cookie", cookie) |
|
} |
|
|
|
return &httputil.ReverseProxy{ |
|
Director: director, |
|
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { |
|
sendJSONError(w, "上游请求失败: "+err.Error(), http.StatusBadGateway) |
|
}, |
|
} |
|
} |
|
|
|
// 生成随机数 |
|
func generateNonce() string { |
|
randomBytes := make([]byte, 8) |
|
rand.Read(randomBytes) |
|
|
|
timeBytes := make([]byte, 4) |
|
timeVal := time.Now().Unix() / 60 |
|
timeBytes[0] = byte((timeVal >> 24) & 0xff) |
|
timeBytes[1] = byte((timeVal >> 16) & 0xff) |
|
timeBytes[2] = byte((timeVal >> 8) & 0xff) |
|
timeBytes[3] = byte(timeVal & 0xff) |
|
|
|
combined := make([]byte, 12) |
|
copy(combined, randomBytes) |
|
copy(combined[8:], timeBytes) |
|
|
|
return base64.StdEncoding.EncodeToString(combined) |
|
} |
|
|
|
// 生成签名随机数 |
|
func generateSignedNonce(secret, nonce string) (string, error) { |
|
secretBytes, err := base64.StdEncoding.DecodeString(secret) |
|
if err != nil { |
|
return "", err |
|
} |
|
nonceBytes, err := base64.StdEncoding.DecodeString(nonce) |
|
if err != nil { |
|
return "", err |
|
} |
|
combined := append(secretBytes, nonceBytes...) |
|
hash := sha256.Sum256(combined) |
|
return base64.StdEncoding.EncodeToString(hash[:]), nil |
|
} |
|
|
|
// 生成签名 |
|
func generateSignature(uri, signedNonce, nonce, data string) (string, error) { |
|
signString := fmt.Sprintf("%s&%s&%s&data=%s", uri, signedNonce, nonce, data) |
|
keyBytes, err := base64.StdEncoding.DecodeString(signedNonce) |
|
if err != nil { |
|
return "", err |
|
} |
|
mac := hmac.New(sha256.New, keyBytes) |
|
mac.Write([]byte(signString)) |
|
signature := mac.Sum(nil) |
|
return base64.StdEncoding.EncodeToString(signature), nil |
|
} |
|
|
|
func sendJSONError(w http.ResponseWriter, message string, statusCode int) { |
|
w.Header().Set("Content-Type", "application/json; charset=UTF-8") |
|
w.WriteHeader(statusCode) |
|
json.NewEncoder(w).Encode(ErrorResponse{Error: message, Code: statusCode}) |
|
} |
|
|
|
// -------------------- 扫码登录相关 -------------------- |
|
|
|
const ( |
|
sid = "xiaomiio" |
|
msgURL = "https://account.xiaomi.com/pass/serviceLogin?sid=" + sid + "&_json=true" |
|
qrURL = "https://account.xiaomi.com/longPolling/loginUrl" |
|
defaultUA = "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36" |
|
qrTimeoutSecond = 60 |
|
) |
|
|
|
type LoginSession struct { |
|
ID string |
|
Created time.Time |
|
LoginURL string // 登录 URL,可生成二维码 |
|
AuthJson map[string]interface{} // 登录成功后返回的数据 |
|
Err string |
|
Status string // pending | success | error |
|
mu sync.RWMutex |
|
} |
|
|
|
func newLoginSession() *LoginSession { |
|
return &LoginSession{ |
|
ID: randomString(16), |
|
Created: time.Now(), |
|
Status: "pending", |
|
} |
|
} |
|
|
|
var ( |
|
sessions = struct { |
|
sync.RWMutex |
|
m map[string]*LoginSession |
|
}{m: make(map[string]*LoginSession)} |
|
) |
|
|
|
// handleQRCodeLogin 入口 |
|
func handleQRCodeLogin(w http.ResponseWriter, r *http.Request) { |
|
if r.Method != http.MethodGet { |
|
w.WriteHeader(http.StatusMethodNotAllowed) |
|
w.Write([]byte("仅支持 GET")) |
|
return |
|
} |
|
sess := newLoginSession() |
|
if err := initQRCodeSession(sess); err != nil { |
|
http.Error(w, "生成二维码失败: "+err.Error(), http.StatusInternalServerError) |
|
return |
|
} |
|
sessions.Lock() |
|
sessions.m[sess.ID] = sess |
|
sessions.Unlock() |
|
go waitLogin(sess) |
|
html := buildQRCodeHTML(sess) |
|
w.Header().Set("Content-Type", "text/html; charset=UTF-8") |
|
w.Write([]byte(html)) |
|
} |
|
|
|
// handleQRCodeStatus 查询状态 |
|
func handleQRCodeStatus(w http.ResponseWriter, r *http.Request) { |
|
id := r.URL.Query().Get("id") |
|
if id == "" { |
|
http.Error(w, "缺少 id 参数", http.StatusBadRequest) |
|
return |
|
} |
|
sessions.RLock() |
|
sess, ok := sessions.m[id] |
|
sessions.RUnlock() |
|
if !ok { |
|
http.Error(w, "会话不存在或已过期", http.StatusNotFound) |
|
return |
|
} |
|
sess.mu.RLock() |
|
defer sess.mu.RUnlock() |
|
resp := map[string]interface{}{"status": sess.Status} |
|
switch sess.Status { |
|
case "success": |
|
resp["data"] = sess.AuthJson |
|
case "error": |
|
resp["error"] = sess.Err |
|
} |
|
w.Header().Set("Content-Type", "application/json; charset=UTF-8") |
|
json.NewEncoder(w).Encode(resp) |
|
} |
|
|
|
// initQRCodeSession 获取二维码 URL |
|
func initQRCodeSession(sess *LoginSession) error { |
|
jar, _ := cookiejar.New(nil) |
|
client := &http.Client{Jar: jar} |
|
deviceId := randomString(16) |
|
req, _ := http.NewRequest("GET", msgURL, nil) |
|
req.Header.Set("User-Agent", defaultUA) |
|
req.Header.Set("Cookie", fmt.Sprintf("deviceId=%s; sdkVersion=3.4.1", deviceId)) |
|
res, err := client.Do(req) |
|
if err != nil { |
|
return err |
|
} |
|
defer res.Body.Close() |
|
if res.StatusCode != 200 { |
|
body, _ := io.ReadAll(res.Body) |
|
return fmt.Errorf("index 状态码 %d: %s", res.StatusCode, string(body)) |
|
} |
|
bodyBytes, _ := io.ReadAll(res.Body) |
|
if len(bodyBytes) < 11 { |
|
return fmt.Errorf("响应数据异常") |
|
} |
|
bodyBytes = bodyBytes[11:] |
|
var indexData map[string]interface{} |
|
if err := json.Unmarshal(bodyBytes, &indexData); err != nil { |
|
return err |
|
} |
|
qs := indexData["qs"].(string) |
|
_sign := indexData["_sign"].(string) |
|
callback := indexData["callback"].(string) |
|
location := indexData["location"].(string) |
|
locParsed, _ := url.Parse(location) |
|
serviceParam := locParsed.Query().Get("serviceParam") |
|
params := url.Values{} |
|
params.Set("_qrsize", "240") |
|
params.Set("qs", qs) |
|
params.Set("bizDeviceType", "") |
|
params.Set("callback", callback) |
|
params.Set("_json", "true") |
|
params.Set("theme", "") |
|
params.Set("sid", sid) |
|
params.Set("needTheme", "false") |
|
params.Set("showActiveX", "false") |
|
params.Set("serviceParam", serviceParam) |
|
params.Set("_local", "zh_CN") |
|
params.Set("_sign", _sign) |
|
params.Set("_dc", fmt.Sprint(time.Now().UnixMilli())) |
|
qrURLWithParam := qrURL + "?" + params.Encode() |
|
req2, _ := http.NewRequest("GET", qrURLWithParam, nil) |
|
req2.Header.Set("User-Agent", defaultUA) |
|
req2.Header.Set("Referer", msgURL) |
|
res2, err := client.Do(req2) |
|
if err != nil { |
|
return err |
|
} |
|
defer res2.Body.Close() |
|
if res2.StatusCode != 200 { |
|
b, _ := io.ReadAll(res2.Body) |
|
return fmt.Errorf("qr 获取失败 %d: %s", res2.StatusCode, string(b)) |
|
} |
|
qrBody, _ := io.ReadAll(res2.Body) |
|
if len(qrBody) < 11 { |
|
return fmt.Errorf("qr 响应异常") |
|
} |
|
qrBody = qrBody[11:] |
|
var qrResp map[string]interface{} |
|
if err := json.Unmarshal(qrBody, &qrResp); err != nil { |
|
return err |
|
} |
|
if int(qrResp["code"].(float64)) != 0 { |
|
return fmt.Errorf("qr 接口错误: %v", qrResp["desc"]) |
|
} |
|
loginURLStr := qrResp["loginUrl"].(string) |
|
lp := qrResp["lp"].(string) |
|
sess.mu.Lock() |
|
sess.LoginURL = loginURLStr |
|
sess.mu.Unlock() |
|
sess.mu.Lock() |
|
sess.AuthJson = map[string]interface{}{"_client": client, "_lp": lp, "_deviceId": deviceId} |
|
sess.mu.Unlock() |
|
return nil |
|
} |
|
|
|
// waitLogin 后台等待扫码结果 |
|
func waitLogin(sess *LoginSession) { |
|
sess.mu.RLock() |
|
client := sess.AuthJson["_client"].(*http.Client) |
|
lp := sess.AuthJson["_lp"].(string) |
|
deviceId := sess.AuthJson["_deviceId"].(string) |
|
sess.mu.RUnlock() |
|
defer func() { |
|
sess.mu.Lock() |
|
delete(sess.AuthJson, "_client") |
|
delete(sess.AuthJson, "_lp") |
|
delete(sess.AuthJson, "_deviceId") |
|
sess.mu.Unlock() |
|
}() |
|
req, _ := http.NewRequest("GET", lp, nil) |
|
req.Header.Set("User-Agent", defaultUA) |
|
req.Header.Set("Connection", "keep-alive") |
|
client.Timeout = time.Second * qrTimeoutSecond |
|
res, err := client.Do(req) |
|
if err != nil { |
|
sess.mu.Lock() |
|
sess.Status = "error" |
|
sess.Err = err.Error() |
|
sess.mu.Unlock() |
|
return |
|
} |
|
defer res.Body.Close() |
|
bodyBytes, _ := io.ReadAll(res.Body) |
|
if len(bodyBytes) < 11 { |
|
sess.mu.Lock() |
|
sess.Status = "error" |
|
sess.Err = "扫码响应异常" |
|
sess.mu.Unlock() |
|
return |
|
} |
|
bodyBytes = bodyBytes[11:] |
|
var lpResp map[string]interface{} |
|
if err := json.Unmarshal(bodyBytes, &lpResp); err != nil { |
|
sess.mu.Lock() |
|
sess.Status = "error" |
|
sess.Err = err.Error() |
|
sess.mu.Unlock() |
|
return |
|
} |
|
if int(lpResp["code"].(float64)) != 0 { |
|
sess.mu.Lock() |
|
sess.Status = "error" |
|
sess.Err = fmt.Sprint(lpResp["desc"]) |
|
sess.mu.Unlock() |
|
return |
|
} |
|
location := lpResp["location"].(string) |
|
req2, _ := http.NewRequest("GET", location, nil) |
|
req2.Header.Set("User-Agent", defaultUA) |
|
req2.Header.Set("Cookie", fmt.Sprintf("deviceId=%s; sdkVersion=3.4.1", deviceId)) |
|
res2, err := client.Do(req2) |
|
if err != nil { |
|
sess.mu.Lock() |
|
sess.Status = "error" |
|
sess.Err = err.Error() |
|
sess.mu.Unlock() |
|
return |
|
} |
|
defer res2.Body.Close() |
|
cookies := client.Jar.Cookies(req2.URL) |
|
cookieMap := make(map[string]string) |
|
for _, c := range cookies { |
|
cookieMap[c.Name] = c.Value |
|
} |
|
auth := map[string]interface{}{ |
|
"userId": func() string { |
|
switch v := lpResp["userId"].(type) { |
|
case string: |
|
return v |
|
case float64: |
|
return strconv.FormatInt(int64(v), 10) |
|
default: |
|
return fmt.Sprint(v) |
|
} |
|
}(), |
|
"ssecurity": lpResp["ssecurity"], |
|
"deviceId": deviceId, |
|
"serviceToken": cookieMap["serviceToken"], |
|
"cUserId": cookieMap["cUserId"], |
|
} |
|
sess.mu.Lock() |
|
sess.Status = "success" |
|
sess.AuthJson = auth |
|
sess.mu.Unlock() |
|
} |
|
|
|
// buildQRCodeHTML 返回展示页面 |
|
func buildQRCodeHTML(sess *LoginSession) string { |
|
qrImgURL := "https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=" + url.QueryEscape(sess.LoginURL) |
|
html := bytes.NewBuffer(nil) |
|
html.WriteString("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>米家扫码登录</title></head><body>") |
|
html.WriteString("<h2>请使用米家 APP 扫码登录</h2>") |
|
html.WriteString("<img src=\"") |
|
html.WriteString(qrImgURL) |
|
html.WriteString("\" alt=\"qr\"/><pre id=\"result\">等待扫码...</pre>") |
|
html.WriteString("<script>const id='") |
|
html.WriteString(sess.ID) |
|
html.WriteString("';function poll(){fetch('/login/qrcode/status?id='+id).then(r=>r.json()).then(j=>{if(j.status==='pending'){setTimeout(poll,2000);}else{document.getElementById('result').textContent=JSON.stringify(j.data||j.error,null,2);}});}poll();</script></body></html>") |
|
return html.String() |
|
} |
|
|
|
// randomString 生成 n 位随机字符串 |
|
func randomString(n int) string { |
|
const letters = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" |
|
result := make([]byte, n) |
|
for i := range result { |
|
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) |
|
result[i] = letters[num.Int64()] |
|
} |
|
return string(result) |
|
} |
|
|
|
// -------------------- 主函数 -------------------- |
|
|
|
func main() { |
|
portFlag := flag.Int("port", 8080, "监听端口") |
|
flag.Parse() |
|
port := fmt.Sprintf(":%d", *portFlag) |
|
|
|
http.HandleFunc("/login/qrcode", handleQRCodeLogin) |
|
http.HandleFunc("/login/qrcode/status", handleQRCodeStatus) |
|
http.HandleFunc("/", corsMiddleware(handleRequest)) |
|
fmt.Printf("小米IoT设备控制服务已启动,监听端口%s\n", port) |
|
log.Fatal(http.ListenAndServe(port, nil)) |
|
} |