Skip to content

Instantly share code, notes, and snippets.

@Zayrick
Last active August 7, 2025 12:04
Show Gist options
  • Save Zayrick/62701850c833c4051356268fa9afc3ff to your computer and use it in GitHub Desktop.
Save Zayrick/62701850c833c4051356268fa9afc3ff to your computer and use it in GitHub Desktop.
一个用于中转和签名转发小米 IoT API 请求的反向代理服务器。该服务接受客户端发来的设备控制请求,自动计算签名并将请求转发至小米的 api.io.mi.com 接口。主要功能包括请求签名计算(基于 HMAC-SHA256)、安全凭证验证、CORS 跨域支持,以及完整的请求重定向和错误处理。

小米 IoT 反向代理及扫码登录服务

该服务提供 Go 服务端Cloudflare Worker 两种部署方式,功能完全一致。
本文档介绍服务功能、部署方式、主要接口与代码结构,方便后续维护及二次开发。

⚠️ 风险警告

请在使用前仔细阅读以下安全提示,明确风险并自行承担责任。

安全风险

  • 🔐 账号安全:扫码登录使用真实米家账号,存在账号被封、Cookie 失效等风险。
  • 🌐 网络安全:反向代理将设备控制请求转发至公网接口,若部署在不安全环境可能被中间人窃听或篡改。
  • 📱 设备权限:获取的 serviceToken/ssecurity 具备完整设备控制权限,泄露将导致设备被他人操控。
  • 🏠 隐私泄露:服务可访问您所有米家设备与家庭信息,请妥善保护部署地址及凭证。

使用建议

  • 测试环境优先:先在测试设备或账号验证稳定性,再迁移到生产环境。
  • 网络隔离:将 Go 服务部署于内网或使用防火墙,仅暴露必要端口;Worker 版启用访问鉴权规则。
  • 定期刷新 Token:定期重新扫码登录,避免长期持有同一凭证。
  • 日志监控:开启访问日志、异常报警,及时发现可疑行为。

免责声明

  • 本项目仅供学习交流,不得用于任何商业用途。
  • 因使用本项目带来的账号封禁、数据泄露等任何后果,均由使用者自行承担。
  • 开发者不对直接或间接损失承担责任;使用即视为接受以上条款。

部署方式对比

部署方式 文件 适用场景 优缺点
Go 服务端 1.go 自托管、内网部署 ✅ 完全控制、性能好 ❌ 需要服务器
Cloudflare Worker 1.js 无服务器、全球分发 ✅ 免运维、高可用 ❌ 受平台限制

1. 功能概述

  1. 设备控制反向代理
    将客户端发起的 POST /app/xxx 请求签名后转发至 https://api.io.mi.com,屏蔽复杂的签名逻辑。
  2. 签名算法实现
    依据米家官方协议,实现 noncesignedNoncesignature 生成流程,保证请求合法。
  3. 扫码登录
    内置 QR 登录流程,浏览器访问 /login/qrcode 即可通过米家 App 扫码获取 serviceTokenssecurity 等授权参数,免去手动抓包。
  4. CORS 支持
    缺省允许所有源、POST/OPTIONS 方法,方便前端直接调用。
  5. 自定义监听端口(Go 版)
    通过 -port CLI 参数传入。

2. 快速开始

2.1 Go 服务端部署

# 运行(默认 8080)
go run .

# 或自定义端口
go run . -port 9090

启动成功后终端输出:

小米IoT设备控制服务已启动,监听端口:9090

2.2 Cloudflare Worker 部署

  1. 创建 Worker
    登录 Cloudflare Dashboard → Workers & Pages → Create Worker

  2. 部署代码
    1.js 内容复制到在线编辑器,点击 Deploy

  3. 自定义域名(可选)
    在 Worker 设置中绑定自定义域名,例如 api.example.com

  4. 测试访问

    # 访问二维码登录页
    curl https://your-worker.workers.dev/login/qrcode

3. HTTP 接口

方法 路径 描述
POST /app/<真实 API> 设备控制请求。Body 为 DeviceRequest JSON,服务端重新封装后转发
GET /login/qrcode 生成二维码登录页
GET /login/qrcode/status?id=<sessionId> 轮询扫码登录状态,成功后返回授权 JSON

3.1 设备控制请求体(示例)

{
  "userId": "123456789",
  "serviceToken": "xxx",
  "deviceId": "abc123",
  "securityToken": "Q0eQ7tKq...",
  "data": {"did":"xxx","method":"get_prop","params":["power"]}
}

说明:userId/serviceToken/deviceId/securityToken/data 五项均为必填,其余字段可扩展。

3.2 扫码登录流程

  1. 浏览器访问登录页面:
    • Go 版http://localhost:8080/login/qrcode
    • Worker 版https://your-worker.workers.dev/login/qrcode
  2. 使用米家 App 扫码确认后,页面自动轮询 /login/qrcode/status
  3. 登录成功返回如下 JSON:
    {
      "status": "success",
      "data": {
        "userId": "123456789",
        "ssecurity": "abcdefg...",
        "deviceId": "xxxx",
        "serviceToken": "yyy",
        "cUserId": "zzz"
      }
    }

4. 签名算法

  1. Nonce
    nonce = base64(random(8) + timestamp(4-bytes, minutes))
  2. SignedNonce
    signedNonce = base64( SHA256( base64Dec(securityToken) + base64Dec(nonce) ) )
  3. Signature
    signString = "<uri>&<signedNonce>&<nonce>&data=<jsonData>"
    signature = base64( HMAC-SHA256( key = base64Dec(signedNonce), msg = signString ) )
    

完整实现位于 generateNonce / generateSignedNonce / generateSignature 三个函数。


5. 代码结构

5.1 Go 版本

  1. 反向代理buildProxy() 构造 httputil.ReverseProxy 并在 Director 注入签名。
  2. 扫码登录:从 const sid... 起直至 randomString,包含所有 QR 登录逻辑。
  3. 主函数:注册三个路由后启动 http.ListenAndServe

5.2 Worker 版本

  1. 事件处理addEventListener('fetch') 监听所有 HTTP 请求。
  2. 路由分发router() 根据路径和方法分发到相应处理器。
  3. 会话管理:使用内存 Map 存储扫码登录会话,通过 event.waitUntil() 后台等待。
  4. 签名算法:基于 Web Crypto API 实现,与 Go 版本逻辑一致。

Worker 环境限制:无法持久化存储,会话仅在单次请求生命周期内有效。


6. 参考

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))
}
// 小米 IoT 平台 Cloudflare Worker —— 反向代理 + 扫码登录
// Author: 自动生成
// =================== 常量配置 ===================
const SID = 'xiaomiio'
const MSG_URL = `https://account.xiaomi.com/pass/serviceLogin?sid=${SID}&_json=true`
const QR_URL = 'https://account.xiaomi.com/longPolling/loginUrl'
const API_HOST = 'https://api.io.mi.com'
const DEFAULT_UA = 'APP/com.xiaomi.mihome APPV/6.0.103 iosPassportSDK/3.9.0 iOS/14.4 miHSTS'
const QR_TIMEOUT_MS = 60_000
// =================== 会话存储 ===================
// Cloudflare Worker 单实例内存级,全局 Map 即可(生命周期与实例一致)
const sessions = new Map() // key -> { status, data, err, loginUrl, lp, createdAt }
// =================== 工具函数 ===================
function randomString(len = 16) {
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
let res = ''
for (let i = 0; i < len; i++) {
res += chars.charAt(Math.floor(Math.random() * chars.length))
}
return res
}
function base64ToUint8Array(base64) {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return bytes
}
function uint8ToBase64(buf) {
let binary = ''
buf.forEach(b => (binary += String.fromCharCode(b)))
return btoa(binary)
}
function generateNonce() {
const randomBytes = new Uint8Array(8)
crypto.getRandomValues(randomBytes)
const timeBytes = new Uint8Array(4)
const time = Math.floor(Date.now() / 1000 / 60)
timeBytes[0] = (time >> 24) & 0xff
timeBytes[1] = (time >> 16) & 0xff
timeBytes[2] = (time >> 8) & 0xff
timeBytes[3] = time & 0xff
const combined = new Uint8Array(12)
combined.set(randomBytes)
combined.set(timeBytes, 8)
return uint8ToBase64(combined)
}
async function generateSignedNonce(secret, nonce) {
const secretBytes = base64ToUint8Array(secret)
const nonceBytes = base64ToUint8Array(nonce)
const combined = new Uint8Array(secretBytes.length + nonceBytes.length)
combined.set(secretBytes)
combined.set(nonceBytes, secretBytes.length)
const hash = await crypto.subtle.digest('SHA-256', combined)
return uint8ToBase64(new Uint8Array(hash))
}
async function generateSignature(urlPath, signedNonce, nonce, data) {
const signString = `${urlPath}&${signedNonce}&${nonce}&data=${data}`
const keyBytes = base64ToUint8Array(signedNonce)
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: { name: 'SHA-256' } }, false, ['sign'])
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signString))
return uint8ToBase64(new Uint8Array(signature))
}
// 从多个 Set-Cookie 头中提取指定 cookie 值
function extractCookie(headers, name) {
const cookies = []
for (const [k, v] of headers.entries()) {
if (k.toLowerCase() === 'set-cookie') cookies.push(v)
}
for (const c of cookies) {
const kv = c.split(';')[0]
const [k, v] = kv.split('=')
if (k === name) return v
}
return ''
}
// =================== 扫码登录实现 ===================
async function initQRCodeSession() {
const deviceId = randomString(16)
// 1. 获取索引页数据
const indexResp = await fetch(MSG_URL, {
headers: {
'User-Agent': DEFAULT_UA,
'Cookie': `deviceId=${deviceId}; sdkVersion=3.4.1`
}
})
if (indexResp.status !== 200) throw new Error('获取 index 失败')
const idxJson = JSON.parse((await indexResp.text()).slice(11))
const { qs, _sign, callback, location } = idxJson
const serviceParam = new URL(location).searchParams.get('serviceParam')
// 2. 获取二维码登录链接
const params = new URLSearchParams({
_qrsize: '240',
qs,
bizDeviceType: '',
callback,
_json: 'true',
theme: '',
sid: SID,
needTheme: 'false',
showActiveX: 'false',
serviceParam,
_local: 'zh_CN',
_sign,
_dc: Date.now().toString()
})
const qrResp = await fetch(`${QR_URL}?${params}`, {
headers: { 'User-Agent': DEFAULT_UA, Referer: MSG_URL }
})
if (qrResp.status !== 200) throw new Error('获取 QR 失败')
const qrJson = JSON.parse((await qrResp.text()).slice(11))
if (qrJson.code !== 0) throw new Error('QR 接口错误: ' + qrJson.desc)
return {
deviceId,
loginUrl: qrJson.loginUrl,
lp: qrJson.lp
}
}
async function waitLogin(sessionKey) {
const sess = sessions.get(sessionKey)
if (!sess) return
try {
const { lp, deviceId } = sess
const lpResp = await fetch(lp, {
headers: { 'User-Agent': DEFAULT_UA, Connection: 'keep-alive' },
cf: { fetchTimeout: QR_TIMEOUT_MS / 1000 }
})
if (lpResp.status !== 200) throw new Error('LP 状态 ' + lpResp.status)
const lpJson = JSON.parse((await lpResp.text()).slice(11))
if (lpJson.code !== 0) throw new Error(lpJson.desc)
// 获取跳转 location
const location = lpJson.location
const jumpResp = await fetch(location, {
headers: {
'User-Agent': DEFAULT_UA,
Cookie: `deviceId=${deviceId}; sdkVersion=3.4.1`
}
})
const serviceToken = extractCookie(jumpResp.headers, 'serviceToken')
const cUserId = extractCookie(jumpResp.headers, 'cUserId')
const auth = {
userId: String(lpJson.userId),
ssecurity: lpJson.ssecurity,
deviceId,
serviceToken,
cUserId
}
sessions.set(sessionKey, { ...sess, status: 'success', data: auth })
} catch (e) {
sessions.set(sessionKey, { ...sess, status: 'error', err: e.message })
}
}
function buildQRCodeHTML(id, loginUrl) {
const qrImgURL = 'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=' + encodeURIComponent(loginUrl)
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>米家扫码登录</title></head><body>
<h2>请使用米家 APP 扫码登录</h2>
<img src="${qrImgURL}" alt="qr"/>
<pre id="result">等待扫码...</pre>
<script>
const id='${id}';
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>`
}
// =================== 主路由 ===================
addEventListener('fetch', event => {
event.respondWith(router(event))
})
async function router(event) {
const request = event.request
const { method } = request
const url = new URL(request.url)
const path = url.pathname
// CORS 头
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
}
// 预检
if (method === 'OPTIONS') return new Response(null, { headers: corsHeaders })
// QR 路由
if (method === 'GET' && path === '/login/qrcode') {
try {
const { deviceId, loginUrl, lp } = await initQRCodeSession()
const id = randomString(16)
sessions.set(id, { status: 'pending', loginUrl, lp, deviceId, createdAt: Date.now() })
// 后台等待扫码
event.waitUntil(waitLogin(id))
return new Response(buildQRCodeHTML(id, loginUrl), {
headers: { 'Content-Type': 'text/html; charset=UTF-8', ...corsHeaders }
})
} catch (e) {
return new Response('生成二维码失败: ' + e.message, { status: 500, headers: corsHeaders })
}
}
if (method === 'GET' && path === '/login/qrcode/status') {
const id = url.searchParams.get('id') || ''
const sess = sessions.get(id)
if (!sess) return new Response(JSON.stringify({ status: 'not_found' }), { headers: { 'Content-Type': 'application/json', ...corsHeaders }, status: 404 })
const resp = { status: sess.status || 'pending' }
if (sess.status === 'success') resp.data = sess.data
else if (sess.status === 'error') resp.error = sess.err
return new Response(JSON.stringify(resp), { headers: { 'Content-Type': 'application/json', ...corsHeaders } })
}
// 设备控制转发
if (method === 'POST' && path.startsWith('/app/')) {
try {
const bodyJson = await request.json()
const { userId, serviceToken, deviceId, securityToken, data } = bodyJson || {}
if (!userId || !serviceToken || !deviceId || !securityToken || data === undefined) {
return new Response('缺少身份验证或 data', { status: 400, headers: { ...corsHeaders, 'Content-Type': 'text/plain; charset=UTF-8' } })
}
const miPath = path.replace(/^\/app/, '')
const nonce = generateNonce()
const signedNonce = await generateSignedNonce(securityToken, nonce)
const dataString = typeof data === 'string' ? data : JSON.stringify(data)
const signature = await generateSignature(miPath, signedNonce, nonce, dataString)
const formParams = new URLSearchParams({ _nonce: nonce, data: dataString, signature })
const headers = {
'User-Agent': DEFAULT_UA,
'x-xiaomi-protocal-flag-cli': 'PROTOCAL-HTTP2',
'Content-Type': 'application/x-www-form-urlencoded',
Cookie: `serviceToken=${serviceToken}; userId=${userId}; PassportDeviceId=${deviceId}`
}
const remote = await fetch(`${API_HOST}${path}`, { method: 'POST', headers, body: formParams })
const respHeaders = new Headers(remote.headers)
Object.entries(corsHeaders).forEach(([k, v]) => respHeaders.set(k, v))
return new Response(remote.body, { status: remote.status, headers: respHeaders })
} catch (e) {
return new Response('处理请求时出错: ' + e.message, { status: 500, headers: { ...corsHeaders, 'Content-Type': 'text/plain; charset=UTF-8' } })
}
}
// 其他路径
return new Response('Not Found', { status: 404, headers: corsHeaders })
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment