使用ESP32自动登录需要WEB页面认证的WIFI网络

一、背景

在很多大型WIFI网络环境中,连接网络不仅需要热点的密码,还需要其他的认证手段,即2次认证。

常见的场景比如:连接上热点后,手机或电脑会自动弹出一个认证页面,需要你输入用户名密码,或者通过手机验证码进行再次认证。

比如这种:

使用ESP32自动登录需要WEB页面认证的WIFI网络_第1张图片使用ESP32自动登录需要WEB页面认证的WIFI网络_第2张图片

通过认证之后,设备才能正常访问互联网。

那么我们如果ESP32,的话,如何去登录这种网络呢?

二、前端常见的WEB登录方式

前端WEB页面在登录方式上,因为不同的厂商不同的开发人员,页面代码上对登录的处理也差异很大。

但归根到底,提交登录信息的方式无非只有两种:GET和POST。

GET方式很少,因为用户输入的username和password会直接暴露在URL中,所以一般还是用POST方法。

POST方法又分为很多种,常见的有:表单form提交、java-script提交。其中java-script提交又可能会使用Ajax、fetch API等。

所以,我们的目的是,利用代码去自动分析当前的网络环境使用的是什么登录方法,并找出HTML页面,或者java-script中的提交登录信息的方法,最终使ESP32实现自动登录。

三、头文件WifiAuth.h

#ifndef WIFI_AUTH_H
#define WIFI_AUTH_H

#include 
#include 
#include 
#include 



class WiFiAuth {
public:
    enum class DataFormat {
        UNKNOWN,
        FORM_URLENCODED,
        JSON
    };
  // 构造函数
  WiFiAuth(const char* authUrl = nullptr, const char* fieldUser = nullptr, const char* fieldPass = nullptr);
  
  // 核心功能
  bool checkRedirect();
  bool submitAuth(const String& username, const String& password);
  bool isAuthPageContent(const String& content);
  String getLastError() const;
  bool fetchAndParseAuthPage(const String& url);
  bool parseMetaRefresh(const String& payload);
  bool parseTraditionalForm(const String& html);
  void parseJSAjaxSubmit(const String& html);
  String findMainJS(const String& html);
  String parseAjaxUrlFromJS(const String& jsContent);
  String parseFieldFromJS(const String& jsContent, const String& fieldType);
  String fetchJSContent(const String& jsUrl);
  String resolveRelativeUrl(const String& url) ;
  String getBaseUrl(const String& fullUrl) ;
  bool testJsUrlExists(const String& url) ;
  DataFormat detectDataFormat(const String& jsContent);

private:
  String _authUrl;
  String _formAction;
  String _fieldUser;
  String _fieldPass;
  String _lastError;

  // 辅助方法
  bool _parseMetaRefresh(const String& html);
  bool _parseAuthPage(const String& html);
  bool _parseAuthPageFromUrl(const String& url);
  String extractFieldName(const String& html, const String& pattern);
  String urlEncode(const String& str);
  String getDomainFromUrl(const String& url);
  DataFormat _dataFormat;
};

#endif

四、源文件

#include "WiFiAuth.h"

WiFiAuth::WiFiAuth(const char* authUrl, const char* fieldUser, const char* fieldPass) :
  _authUrl(authUrl ? authUrl : "http://1.1.1.1/login"),
  _fieldUser(fieldUser ? fieldUser : "username"),
  _fieldPass(fieldPass ? fieldPass : "password") {
  _lastError.reserve(128);
  _formAction.reserve(64);
}

bool WiFiAuth::checkRedirect() {
    // 初始化HTTP客户端并配置
    WiFiClient client;
    HTTPClient http;
    http.setFollowRedirects(HTTPC_DISABLE_FOLLOW_REDIRECTS);
    http.setTimeout(3000);
    http.setUserAgent("ESP32-WiFiAuth");

    // 尝试连接目标网站
    if (!http.begin(client, "http://www.baidu.com")) {
        _lastError = "HTTP连接初始化失败";
        return false;
    }

    // 执行GET请求并获取响应信息
    int httpCode = http.GET();
    String location = http.getLocation();
    bool isHttpSuccess = (httpCode == HTTP_CODE_OK);
    String payload = isHttpSuccess ? http.getString() : "";
    http.end();

    /* 情况1:处理HTTP重定向
     * - 状态码为302/307
     * - Location头不为空
     * - 重定向目标不是百度域名 */
    bool isHttpRedirect = (httpCode == HTTP_CODE_FOUND || httpCode == HTTP_CODE_TEMPORARY_REDIRECT);
    if (isHttpRedirect && 
        !location.isEmpty() && 
        location.indexOf("baidu.com") == -1) {
        _authUrl = location;
        return fetchAndParseAuthPage(_authUrl);
    }

    /* 情况2:处理HTML meta refresh重定向
     * - 状态码为200
     * - 页面包含meta refresh标签 */
    if (isHttpSuccess && parseMetaRefresh(payload)) {
        Serial.println("处理HTML meta refresh重定向");
        return fetchAndParseAuthPage(_authUrl);
    }

    /* 情况3:无认证要求
     * - 设置相应的错误信息 */
    if (isHttpSuccess) {
        _lastError = "正常访问百度首页,无需认证";
    } else {
        _lastError = "未检测到认证要求,HTTP状态码: " + String(httpCode);
    }
    
    return false;
}

bool WiFiAuth::fetchAndParseAuthPage(const String& url) {
    WiFiClient client;
    HTTPClient http;
    
    if (url.startsWith("https")) {
        WiFiClientSecure secureClient;
        secureClient.setInsecure();
        if (!http.begin(secureClient, url)) {
            _lastError = "HTTPS begin failed";
            return false;
        }
    } else if (!http.begin(client, url)) {
        _lastError = "HTTP begin failed";
        return false;
    }

    int httpCode = http.GET();
    if (httpCode != HTTP_CODE_OK) {
        _lastError = "获取认证页失败,状态码: " + String(httpCode);
        http.end();
        return false;
    }

    String payload = http.getString();
    http.end();

    if (!_parseAuthPage(payload)) {
        _lastError = "解析认证页失败";
        return false;
    }
    return true;
}

bool WiFiAuth::parseMetaRefresh(const String& payload) {
    int metaPos = payload.indexOf("http-equiv=\"refresh\"");
    if (metaPos == -1) return false;

    int urlPos = payload.indexOf("URL=", metaPos);
    if (urlPos == -1) return false;

    urlPos += 4;
    int urlEnd = payload.indexOf("\"", urlPos);
    if (urlEnd == -1) urlEnd = payload.indexOf("'", urlPos);
    
    if (urlEnd > urlPos) {
        _authUrl = payload.substring(urlPos, urlEnd);
        if (_authUrl.startsWith("/")) {
            _authUrl = "http://" + getDomainFromUrl("http://www.baidu.com") + _authUrl;
        }
        return true;
    }
    return false;
}

String WiFiAuth::getDomainFromUrl(const String& url) {
    int start = url.indexOf("://") + 3;
    if (start < 3) start = 0;
    
    int end = url.indexOf("/", start);
    if (end == -1) end = url.length();
    
    return url.substring(0, end);
}

String WiFiAuth::extractFieldName(const String& html, const String& pattern) {
    int pos = html.indexOf(pattern);
    if (pos == -1) return "";

    pos += pattern.length();
    int start = html.indexOf("\"", pos) + 1;
    if (start <= 0) start = html.indexOf("'", pos) + 1;
    if (start <= 0) return "";

    int end = html.indexOf(html[start-1] == '\"' ? "\"" : "'", start);
    if (end <= start) return "";

    return html.substring(start, end);
}

bool WiFiAuth::_parseAuthPage(const String& html) {
    Serial.println("_parseAuthPage called!");
    bool isTraditionalForm = parseTraditionalForm(html);
    
    if (!isTraditionalForm) {
        parseJSAjaxSubmit(html);
    }

    return !_formAction.isEmpty() && !_fieldUser.isEmpty() && !_fieldPass.isEmpty();
}

bool WiFiAuth::parseTraditionalForm(const String& html) {
    int formStart = html.indexOf(" pos) {
                    String url = jsContent.substring(startQuotePos + 1, endQuotePos);
                    if(url.startsWith("/")) {
                        return getDomainFromUrl(_authUrl) + url;
                    }
                    return url;
                }
            }
        }
    }
    
    return "";
}

String WiFiAuth::parseFieldFromJS(const String& jsContent, const String& fieldType) {
    String pattern = "\\." + fieldType + "\\s*=\\s*([^;]+)";
    int pos = jsContent.indexOf(pattern);
    if (pos != -1) {
        int valStart = jsContent.indexOf("\"", pos) + 1;
        int valEnd = jsContent.indexOf("\"", valStart);
        return jsContent.substring(valStart, valEnd);
    }
    return "";
}

bool WiFiAuth::_parseAuthPageFromUrl(const String& url) {
    WiFiClient client;
    HTTPClient http;
    
    if (url.startsWith("https")) {
        WiFiClientSecure secureClient;
        secureClient.setInsecure();
        if (!http.begin(secureClient, url)) return false;
    } else if (!http.begin(client, url)) {
        return false;
    }

    int httpCode = http.GET();
    if (httpCode != HTTP_CODE_OK) {
        http.end();
        return false;
    }

    bool result = _parseAuthPage(http.getString());
    http.end();
    return result;
}

String WiFiAuth::urlEncode(const String& str) {
    String encoded;
    const char* hex = "0123456789ABCDEF";
    
    for (unsigned int i = 0; i < str.length(); i++) {
        char c = str.charAt(i);
        if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
            encoded += c;
        } else if (c == ' ') {
            encoded += '+';
        } else {
            encoded += '%';
            encoded += hex[(c >> 4) & 0xF];
            encoded += hex[c & 0xF];
        }
    }
    return encoded;
}

bool WiFiAuth::submitAuth(const String& username, const String& password) {
    if (_formAction.isEmpty() && !_parseAuthPageFromUrl(_authUrl)) {
        _lastError = "No form action available";
        return false;
    }

    WiFiClient client;
    HTTPClient http;
    
    if (_formAction.startsWith("https")) {
        WiFiClientSecure secureClient;
        secureClient.setInsecure();
        if (!http.begin(secureClient, _formAction)) {
            _lastError = "HTTPS begin failed";
            return false;
        }
    } else if (!http.begin(client, _formAction)) {
        _lastError = "HTTP begin failed";
        return false;
    }

    String postData;
    
    if (_dataFormat == DataFormat::JSON) {
        http.addHeader("Content-Type", "application/json");
        postData = "{\"" + _fieldUser + "\":\"" + username + 
                  "\",\"" + _fieldPass + "\":\"" + password + "\"}";
    } else {
        // 默认为form-urlencoded
        http.addHeader("Content-Type", "application/x-www-form-urlencoded");
        postData = _fieldUser + "=" + urlEncode(username) + 
                  "&" + _fieldPass + "=" + urlEncode(password);
    }
    
    Serial.print("submitAuth postData = ");Serial.println(postData);
    int httpCode = http.POST(postData);
    String response = http.getString();
    http.end();

    // 更全面的认证成功判断逻辑
    bool success = false;
    if (httpCode == HTTP_CODE_OK || 
        httpCode == HTTP_CODE_FOUND || 
        httpCode == HTTP_CODE_TEMPORARY_REDIRECT) {
        
        // 检查响应内容中的成功标志
        if (_dataFormat == DataFormat::JSON) {
            // 解析JSON响应
            DynamicJsonDocument doc(1024);
            DeserializationError error = deserializeJson(doc, response);
            
            if (!error) {
                // 检查常见的JSON响应格式
                if (doc.containsKey("success") && doc["success"] == true) {
                    success = true;
                } 
                else if (doc.containsKey("reply_code") && doc["reply_code"] == 0) {
                    success = true;
                }
                else if (doc.containsKey("status") && doc["status"] == "success") {
                    success = true;
                }
            }
        } else {
            // 检查HTML响应中的常见成功标志
            if (response.indexOf("认证成功") != -1 || 
                response.indexOf("登录成功") != -1 ||
                response.indexOf("success") != -1 ||
                response.indexOf("欢迎") != -1) {
                success = true;
            }
        }
        
        // 检查重定向URL是否包含成功标志
        String location = http.getLocation();
        if (!location.isEmpty() && 
            (location.indexOf("success") != -1 || 
             location.indexOf("welcome") != -1)) {
            success = true;
        }
    }

    if (!success) {
        // 尝试从响应中提取错误信息
        String errorMsg;
        if (_dataFormat == DataFormat::JSON) {
            DynamicJsonDocument doc(512);
            DeserializationError error = deserializeJson(doc, response);
            if (!error) {
                if (doc.containsKey("message")) {
                    errorMsg = doc["message"].as();
                } else if (doc.containsKey("error")) {
                    errorMsg = doc["error"].as();
                } else if (doc.containsKey("reply_msg")) {
                    errorMsg = doc["reply_msg"].as();
                }
            }
        }
        
        if (errorMsg.isEmpty()) {
            // 从HTML中提取错误信息
            int errorStart = response.indexOf("error-msg");
            if (errorStart == -1) errorStart = response.indexOf("errormsg");
            if (errorStart != -1) {
                int msgStart = response.indexOf(">", errorStart) + 1;
                int msgEnd = response.indexOf("<", msgStart);
                if (msgEnd > msgStart) {
                    errorMsg = response.substring(msgStart, msgEnd);
                }
            }
        }
        
        _lastError = String("Auth failed (") + httpCode + ")";
        if (!errorMsg.isEmpty()) {
            _lastError += ": " + errorMsg;
        } else if (!response.isEmpty()) {
            // 只显示响应前100个字符,避免内存问题
            _lastError += ": " + response.substring(0, 100);
        }
    }

    return success;
}

String WiFiAuth::getLastError() const {
    return _lastError;
}

五、使用方法

1.首先你要确保正常连接上WIFI热点

连接热点的部分代码随便找找就能找到,不是本文重点。

连接上WIFI后调后这段代码。

WiFiAuth wifiAuth("http://auth.example.com");
  

// 检查是否需要认证
if (wifiAuth.checkRedirect()) {
    // 此时已自动完成:
    // 1. 重定向检测
    // 2. 认证页获取
    // 3. 表单解析
    Serial.println("准备提交认证...");
    if (wifiAuth.submitAuth("你的用户名", "你的密码")) {
        Serial.println("认证成功!");
    } else {
        Serial.println("认证失败: " + wifiAuth.getLastError());
    }
    } else {
        Serial.println("无需认证: " + wifiAuth.getLastError());
  }

2.一点说明

代码会在WIFI连上热点后,通过checkRedirect()方法去尝试连接www.baidu.com,

如果能正常打开,说明这个网络是不需要认证或者是已经认证过了。有的网络只需要在首次连接时认证,后台会记录设备MAC地址,后续连接就不需要认证了。

如果不能正常打开,代码会检查返回的信息是直接跳转还是Meta refresh,并接着打开跳转的页面,通过抓取HTML表单、分析java-script文件的方式,最终找出登录的API接口。

wifiAuth.submitAuth("你的用户名", "你的密码")方法将你的认证信息填进去,ESP32就会向登录的API接口提交你的用户名和密码,通过返回信息判断是否登录成功。

3.代码还有很多需要完善的地方

代码仅在我目前的网络环境中(认证服务器返回的一个js文件中找到了Ajax的POST json方式提交的登录信息 )测试通过,其他网络环境我没有测试过,可能会存在很多BUG和不完善的地方。

同时如果是其他客户端方式登录(CS),我还没想到怎么解决。

你可能感兴趣的:(前端,单片机,嵌入式硬件,c++)