在很多大型WIFI网络环境中,连接网络不仅需要热点的密码,还需要其他的认证手段,即2次认证。
常见的场景比如:连接上热点后,手机或电脑会自动弹出一个认证页面,需要你输入用户名密码,或者通过手机验证码进行再次认证。
比如这种:
通过认证之后,设备才能正常访问互联网。
那么我们如果ESP32,的话,如何去登录这种网络呢?
前端WEB页面在登录方式上,因为不同的厂商不同的开发人员,页面代码上对登录的处理也差异很大。
但归根到底,提交登录信息的方式无非只有两种:GET和POST。
GET方式很少,因为用户输入的username和password会直接暴露在URL中,所以一般还是用POST方法。
POST方法又分为很多种,常见的有:表单form提交、java-script提交。其中java-script提交又可能会使用Ajax、fetch API等。
所以,我们的目的是,利用代码去自动分析当前的网络环境使用的是什么登录方法,并找出HTML页面,或者java-script中的提交登录信息的方法,最终使ESP32实现自动登录。
#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("
连接热点的部分代码随便找找就能找到,不是本文重点。
连接上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());
}
代码会在WIFI连上热点后,通过checkRedirect()方法去尝试连接www.baidu.com,
如果能正常打开,说明这个网络是不需要认证或者是已经认证过了。有的网络只需要在首次连接时认证,后台会记录设备MAC地址,后续连接就不需要认证了。
如果不能正常打开,代码会检查返回的信息是直接跳转还是Meta refresh,并接着打开跳转的页面,通过抓取HTML表单、分析java-script文件的方式,最终找出登录的API接口。
wifiAuth.submitAuth("你的用户名", "你的密码")方法将你的认证信息填进去,ESP32就会向登录的API接口提交你的用户名和密码,通过返回信息判断是否登录成功。
代码仅在我目前的网络环境中(认证服务器返回的一个js文件中找到了Ajax的POST json方式提交的登录信息 )测试通过,其他网络环境我没有测试过,可能会存在很多BUG和不完善的地方。
同时如果是其他客户端方式登录(CS),我还没想到怎么解决。