☰
OpenPortalServer V3.3.5.6商用WiFi认证部署与Portal协议避坑指南
2026/10/6 3:44:50 网站建设 项目流程

简介:OpenPortalServer V3.3.5.6 是一套基于Java的开源Portal认证服务端程序,面向需要对接华为、H3C、锐捷、爱快等设备完成用户接入认证的网络管理员与二次开发人员,适用于园区网、运营商热点、酒店/校园等接入场景,支持标准Portal、Portal V1/V2、CMCC协议以及PAP/CHAP认证方式。压缩包内共1477个文件,主要包括Java编译类、JSP页面、前端CSS/JS、第三方jar依赖、Spring/MyBatis等框架配置、SQL脚本、sh/bat启动部署脚本、APK安装包及少量文档,整体约51.99MB,从业务逻辑到前端展示均有覆盖,目录结构清晰,便于定位业务代码与运维配置。已有1366人学习/下载。借助该包可以直接部署一套多协议Portal服务端,配合内置的一键认证、微信/短信/动态密码等扩展认证方式,可快速验证不同场景下的接入流程;同时SpringMVC+Shiro+Ehcache等框架组合和完整项目布局,也为开发人员理解企业级认证系统设计、进行功能改造或排错提供了可直接参考的实例。

1. OpenPortalServer:一个被低估的Portal服务端程序

很多刚接商业WiFi项目的朋友都默认认证页面是路由器送的,实际上只要AC开了Portal认证,整条链路的控制权就落在Portal服务端程序上。我们这次拆的OpenPortalServer V3.3.5.6(2016年1月16日发布的Stable版),正是一个典型Portal服务端程序:用户在WiFi下打开任何网页,AC先把访问引到Portal平台,平台弹出登录页,收账号密码,查Radius或本地库,再命令AC放行。我当初在一家连锁酒店调它时,页面弹不出来、认证后不通网、计费对不上账的问题挨个踩了一遍,最后发现大半不是程序不行,而是Portal协议在AC和服务端之间的细节没对齐。这篇就把部署、联调、避坑一次写全,适合手里有AC要接Portal认证的网工,也适合做WiFi认证系统二次开发的工程师。

2. Portal协议链路拆解:OpenPortalServer到底在和谁说话

2.1 从用户连上WiFi到认证成功:四段报文的旅程

要理解OpenPortalServer,先得看它在整条链路上的位置。用户接入一个未认证的WiFi后,AC怎么知道要先让他认证?常见有两种触发方式。一是AC强制:用户获到IP后发起HTTP请求,AC一看目标会话未认证,直接回302跳到Portal服务器地址。二是HTTP劫持重定向:用户访问任意域名,AC拦截后发现目标地址未放行,把URL改写成Portal的登录地址。

不管哪种方式,从AC视角看,整个过程就是几段Portal协议报文配合一次HTTP交互。我把最常见的流程整理成下面这张表:

阶段报文方向报文类型实际作用
挑战协商AC到服务端REQ_CHALLENGEAC询问服务端是否准备好接纳该用户
挑战应答服务端到ACACK_CHALLENGE服务端返回生成的挑战字,为CHAP做准备
页面推送AC到用户HTTP 302AC把用户浏览器引到Portal登录页
账号提交用户到服务端HTTP POST用户在页面输入账号密码提交到服务端
授权上线服务端到ACREQ_AUTH服务端确认账号合法,请求AC放行该用户
授权确认AC到服务端ACK_AUTHAC回执确认,用户正式上网
下线通知AC到服务端NTF_LOGOUT用户断开时AC通知服务端回收会话

REQ_CHALLENGE和REQ_AUTH是服务端和AC之间的UDP私房话,根本不走HTTP。很多人在LNMP环境里翻遍日志也找不到Portal报文,就是因为它压根不会出现在Nginx的访问日志里。OpenPortalServer V3.3.5.6的主要工作,就是把上面这几个动作串起来:收到REQ_CHALLENGE后回带挑战字的包,渲染登录页,收POST,验证账号,发REQ_AUTH,最后把AC的确认写进在线表。

2.2 为什么服务端要自己维护会话状态:协议报文的黑匣子

Portal协议报文做得不像现代HTTP那样自带会话。常见报文里只有报文长度、序列号、类型、用户IP、用户MAC、NAS IP、结果码和可选属性段,没有session_id这种东西。服务端要区分“这个认证请求是哪个用户产生的”,只能靠userip、usermac、nasip、ssid拼一个复合键。这就是为什么这类服务端程序都有一张在线会话表。

我一般会建这么一张表来维护状态:

CREATE TABLE portal_session ( session_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_ip INT UNSIGNED NOT NULL, user_mac CHAR(17) NOT NULL, nas_ip INT UNSIGNED NOT NULL, ssid VARCHAR(64) DEFAULT '', challenge CHAR(32) NOT NULL COMMENT '服务端下发给AC的挑战字', auth_state TINYINT DEFAULT 0 COMMENT '0等待认证 1已认证', login_time DATETIME NOT NULL, logout_time DATETIME DEFAULT NULL, KEY idx_nas_time (nas_ip, login_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这个表有两个关键设计。第一,user_ip和nas_ip用INT UNSIGNED保存,是为了跟AC报文里32位地址字段做字节序转换时少踩坑,很多认证失败就是IP字节序不对导致的。第二,challenge字段必须原样保存服务端自己下发的挑战字,后面算CHAP响应时要用它做MD5的输入,取错一个字节整个认证就废了。

把会话放在服务端集中维护,而不是依赖AC记住状态,是有实际原因的。一台AC通常管着几十个AP和几百个并发用户,AC的重启又远比服务器频繁,AC一旦重启会话就丢。Portal服务端这台机器只要不挂,用户在线和计费状态就可以继续维持,这是商业WiFi对账的基本要求。所以你会看到OpenPortalServer这类程序一般都有“AC心跳超时检测”和“会话自动清理”两个后台任务,前者判断AC还在不在,后者把掉线用户查出来做下线处理。

2.3 模块边界:认证核心和能拆出去的扩展

整个程序按功能可以切成四个模块:AC对接层、页面层、账号校验层、数据层。AC对接层只干一件事,打包和解包Portal报文,包括挑战字生成、长度字段计算、属性段拼接;页面层决定用户看到的是登录页、自助改密页还是下线提示页;账号校验层对接Radius或本地库,把用户提交的密码翻译成后端能验证的格式;数据层负责在线表、日志表和扩展业务表。

这四层里,AC对接层是最忌讳改动的部分,解包字段顺序错一个字节就可能整个用户识别不出来。页面层则是被改得最频繁的,商场要换广告图、酒店要加协议勾选、学校要跳通知页,都在这一层动。所以我部署时一般把AC对接层做成独立常驻进程,页面层走PHP-FPM的web根目录,这样改页面模板不用重启UDP服务端。V3.3.5.6这个版本的模块化边界大致如此,改动时能少一点互相牵连的玄学故障。

3. 部署OpenPortalServer V3.3.5.6:从解压到能跑通的最小步骤

3.1 为什么用PHP-FPM加MySQL的经典组合

这个版本是2016年的Stable版,服务端程序主体是PHP,页面、回调、状态查询都在同一套环境下维护,部署门槛低。我一般把它放进LNMP,PHP走PHP-FPM进程,MySQL单独一台或同机部署都可以。PHP的好处是改页面模板方便,登录页面和回调脚本能直接复用一套session机制,不用像Java服务那样为静态资源单独做映射。

环境参数我按下面这个基准配:

参数项推荐值说明
PHP版本5.6及以上老环境别急着迁高版本PHP,部分报文流处理逻辑依赖旧特性
MySQL5.6 / 5.7生产环境建议独立主机,会话表读写比较频繁
UDP端口2000和AC通信的专用端口,AC侧也必须填这个
HTTP端口8080认证页面端口,和UDP端口不是一个东西

很多人部署时只开了8080的防火墙,忘了放行UDP 2000,结果AC侧一直报“portal connect timeout”。这个坑在3.3里还会细说,先把端口规划清楚。

3.2 安装步骤:解压、导库、改配置

部署路径我习惯放在/opt/OpenPortalServer,避免放在网站根目录里被web扫描到。下面这段是完整的最小安装流程:

# 假设源码包已经上传到 /data/soft mkdir -p /opt/OpenPortalServer tar -zxf /data/soft/OpenPortalServer_V3.3.5.6.tar.gz -C /opt/OpenPortalServer/ --strip-components=1 cd /opt/OpenPortalServer # 导入初始表结构,库名按项目实际改 mysql -uportal_user -p -D portal_db < ./sql/portal_db.sql # 编辑主配置:数据库、UDP监听端口、AC密钥 vi ./config/config.php

这里做了三件事:解压到固定目录并去掉顶层目录名,导入数据库结构,改配置文件。注意我用--strip-components=1是为了把解压后那层目录剥掉,否则路径会嵌套得很深,后面写systemd托管脚本时容易把路径搞错。

config.php里必须确认的参数如下,其它保持默认即可:

$portal_cfg = [ // 服务端IP,AC上填的Portal服务器地址就是这个 'portal_ip' => '192.168.6.10', // 与AC通信的UDP端口 'portal_port' => 2000, // AC共享密钥,两边必须完全一致 'portal_secret' => 'your-ac-secret', // 认证页面的HTTP端口 'web_port' => 8080, // 数据库连接 'db_dsn' => 'mysql:host=127.0.0.1;dbname=portal_db', 'db_user' => 'portal_user', 'db_pass' => 'portal_pass', ];

portal_ip这里容易翻车,要填AC能路由到、能回包的地址。如果AC和服务端在同一个二层,直接写服务端eth0的地址就行;如果跨三层组网,AC上必须配到服务端的静态路由,否则REQ_CHALLENGE能发过来,ACK_CHALLENGE回不去。

3.3 启动与确认监听:先证明端口活着

启动后别急着接真实AC,先验证一下UDP端口和HTTP端口都在监听:

ss -ulnp | grep 2000 ss -tlnp | grep 8080 curl -I http://192.168.6.10:8080/portal/login

第一句看UDP 2000是否有独立进程占着,第二句看8080,第三句用curl探测HTTP服务是否返回页面。如果UDP端口没进程监听,说明服务端代码没有以常驻进程方式跑起来,后面AC怎么触发都不会有反应。

防火墙放行这里我吃过亏,AC和服务端之间只要放行UDP就行,别把HTTP和UDP混在一起:

# 只需要放行AC到Portal服务端的UDP 2000 iptables -A INPUT -p udp --dport 2000 -j ACCEPT

注意别只放HTTP端口就以为完事了。Portal协议报文走的是UDP,和认证页面的TCP端口是两回事。我曾经遇到过8080端口通、页面能打开,但AC一直报“connect timeout”,排查到最后才发现防火墙只放行了TCP,UDP 2000被静默丢弃了。

4. 接入真实AC:报文差异、认证回调与URL组装

4.1 识别Portal协议的四种变体

OpenPortalServer要接的AC厂商五花八门,不同厂商的Portal协议长得不一样。常见变体我整理成一张表:

变体常见端口报文结构特征主流认证方式
CMCC运营商WiFiUDP 2000包头带长度和序列号,用户IP从固定偏移开始CHAP为主
H3CUDP 2000报文携带challenge和chap密码,尾部带attr段CHAP / PAP
CiscoUDP 2000附近简单PAP偏多,包长字段比较短PAP
锐捷UDP 2000私有属性段在尾部,长度字段容易坑CHAP

判断方法很直接:抓AC发出的第一个包,数一下前几个字节。包头前四个字节通常是报文长度,第五第六字节是类型和序列号,后面才是IP。如果类型字节和长度对得上,先按标准解析;对不上再按变体处理。OpenPortalServer里这个识别一般做成白名单配置,同一个服务端可以挂多台不同厂商的AC,按nas_ip区分用哪套解析规则。

4.2 认证回调最小实现:挑战字对了才能过

登录页POST数据后,服务端要做三件事:从会话里取挑战字,算CHAP响应,查账号源。下面这段是核心回调的骨架,我习惯把它单独放一个文件,方便替换成自己的Radius封装:

<?php // login_callback.php 骨架,省略了数据库封装 $username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? ''; $userip = $_SESSION['userip'] ?? null; $challenge = $_SESSION['challenge'] ?? null; if (!$userip || !$challenge) { header('Location: /portal/error.php?err=sess'); exit; } // CHAP-Password 通常等于 md5(一个字节的标识 + 明文密码 + challenge) $chap_response = md5("\x01" . $password . $challenge); // 这里替换成你的Radius客户端或数据库校验 $ok = verify_account($username, $chap_response); if ($ok) { // 通知AC放行,内部打包REQ_AUTH并发送到nas_ip的UDP 2000 portal_auth_request($_SESSION['nas_ip'], $userip, $ok['band']); header('Location: /portal/success.php'); } else { header('Location: /portal/error.php?err=pwd'); }

这段代码的核心约束是$challenge必须来自服务端自己下发的ACK_CHALLENGE里的挑战字,也就是会话表里存的那个值。很多翻车都出在页面端又向AC重拿了一次挑战字,两边challenge不一致,导致算出来的CHAP响应完全对不上。

verify_account替换成Radius时,可以把$chap_response当作RADIUS报文里的CHAP-Password直接塞进去。这个字段总共36字节,第1字节是challenge identifier,后32字节是MD5结果。封装Radius时要特别注意字段对齐,否则后端认证服务器会直接回Access-Reject,而且不会告诉你错在哪。

4.3 认证页URL组装规则:AC带过来的参数怎么接

AC重定向时会把用户上下文用GET参数带过来,常见字段是下面这些:

参数含义是否必带
userip用户获得的内网IP必带
usermac用户MAC,通常去掉冒号多数必带
nasipAC的地址必带
ssid用户连的无线网络名可选但推荐带
service认证后要放行的服务名可选

组装URL时我习惯先把所有参数取出来做一遍校验,再拼进页面表单的隐藏域:

<?php // 接收AC重定向参数,拼到登录表单隐藏域 $userip = rawurldecode($_GET['userip'] ?? ''); $nasip = rawurldecode($_GET['nasip'] ?? ''); $ssid = rawurldecode($_GET['ssid'] ?? ''); // 后续表单提交时要把这些参数原样带回来 $hidden = '<input type="hidden" name="userip" value="' . htmlspecialchars($userip) . '">'; $hidden .= '<input type="hidden" name="nasip" value="' . htmlspecialchars($nasip) . '">'; echo $hidden;

这里有个特别提醒:userip要当字符串处理,别用intval转,很多IP地址被转成整数之后,再拼回去就变了样。中文SSID是最容易出问题的地方,AC如果没做URL编码就把SSID塞进Location头,浏览器会直接报“无法解析地址”,所以服务端解析时先用rawurldecode还原,再重新编码拼到表单里。

4.4 联调验证清单:每步都有确定产出

部署完成之后,建议按下面这张表走一遍,不要跳步直接接真实门店环境:

验证动作期望结果失败时看哪里
未认证用户访问HTTP被AC重定向到Portal登录页AC的Portal配置、服务端UDP收包
输入正确账号提交页面跳成功页,用户可上网REQ_AUTH发送记录、AC放行日志
输入错误账号提交页面提示密码错误verify_account返回值、数据库查询
用户主动断开重连老会话被释放,新会话正常NTF_LOGOUT包、会话清理任务

这样做的意义在于每一步都有明确的产出文件可以对照。如果第2步失败,先判断是“页面没跳”还是“AC没放行”,前者看HTTP交互,后者看UDP报文。把问题定位到单层,再改配置,效率比在配置文件里盲调高得多。

5. 避坑排查:五个翻车点覆盖最常见的死法

5.1 链路和页面层的三个坑:页面能弹但认证不过

坑1:认证后AC不放行

现象:页面明确提示认证成功,但用户还是不能上网,AC在线列表里也找不到这个用户。

原因:服务端发出的REQ_AUTH报文里,用户IP字段用的是主机字节序,而AC期待的是网络字节序。很多Portal服务端程序在处理32位IP时,小端机器上直接打包,跨三层组网时AC解析出来的IP地址就不对,自然拒绝放行。

解决:打包前统一调用inet_aton把IP转成网络字节序,无论是PHP还是Go实现都要加这一步。具体操作时抓包对比AC发出的REQ_CHALLENGE里用户IP的字节顺序,以它为基准反向修正服务端打包逻辑,改完再用tcpdump验证一次。

坑2:CHAP挑战值对不上

现象:服务端日志出现challenge mismatch,后端Radius返回Access-Reject。

原因:认证回调阶段又向AC二次请求了挑战值,而不是用ACK_CHALLENGE里自己生成的那个值。挑战值被AC重新下发后,可能带着不同的字节序或截断,算出来的MD5结果自然跟Radius侧对不上。

解决:挑战值只在ACK_CHALLENGE时生成一次,写入会话表,认证回调阶段从这个字段读取。AC如果在下发页面URL时也带了challenge参数,服务端要以会话表为准覆盖它,不要信任URL里的值。

坑3:中文SSID导致登录页直接打不开

现象:AC重定向地址里的ssid是中文,浏览器提示“无法解析地址”或页面转成乱码。

原因:AC把原始字符串直接塞进Location头,没有做URL编码,中文按终端本地编码传到了服务端。

解决:服务端解析参数后用rawurldecode还原,再重新hash拼到表单里;所有输出到页面的参数统一加htmlspecialchars转义,页面模板固定声明utf-8的meta标签。这个坑在商场WiFi里特别常见,因为SSID大多带中文字段。

5.2 部署环境的两个坑:进程挂错位置和下线条目丢失

坑4:登录按钮点了没有反应

现象:认证页能打开,输入账号密码提交后浏览器一直转圈,服务端没有任何新的UDP出包。

原因:Portal的UDP 2000端口没有独立常驻进程,服务端被挂在了PHP-FPM的web请求生命周期里,页面请求结束后进程就释放了,AC回包根本找不到监听者。

解决:UDP协议栈要单独用systemd托管常驻进程,绑定0.0.0.0:2000,HTTP页面才交给PHP-FPM处理。分开之后还有一个好处,改页面模板不用重启UDP服务端,两个职责互不影响。我用systemd托管时核心就两行配置,Restart=always和ExecStart指向bin目录下的常驻脚本。

坑5:晚上高峰期下线记录大面积丢失

现象:深夜在线人数骤降后,log表里的下线记录对不上账号实际使用时长,计费对账对不上。

原因:会话表只有主键索引,下线更新语句走全表扫描,高并发时锁等待超时,更新失败后整个下线流程中断。

解决:给portal_session表加联合索引,让下线更新走索引快速定位,再配合定期清理脚本把过期会话清走:

ALTER TABLE portal_session ADD INDEX idx_nas_time (nas_ip, login_time); DELETE FROM portal_session WHERE auth_state=1 AND login_time < NOW() - INTERVAL 7 DAY;

这两条SQL解决的是两个不同的问题。加索引解决高并发更新锁等待,清理过期记录解决表无限膨胀。我建议清理脚本放进crontab,每天凌晨跑一次,高峰时段不要动这张表。

6. 进阶调试技巧:抓包加模拟报文,把黑匣子变成可验证的协议栈

真正把Portal协议吃透的方式是抓包。拿到OpenPortalServer V3.3.5.6之后,我建议你做的第一件事不是看代码,而是抓一次真实报文。命令很简单:

# 在Portal服务端上抓AC发来的UDP 2000报文 tcpdump -i eth0 -nn -s 0 udp port 2000 -w portal_debug.cap

在AC侧触发一个未认证用户访问网页,等10秒后Ctrl-C停止抓包,然后用十六进制方式查看报文前几个字节:长度、类型、序列号、用户IP。先搞清这十六个字节的含义,后面改配置就不玄学了。

如果不方便在真机上触发,还可以用socat模拟AC发一个最小请求包:

# 构造一个17字节的REQ_CHALLENGE,类型字段为1 printf '\x00\x11\x00\x01\x00\x00\x00\x01\x0a\x00\x00\x66\x00' | nc -u 127.0.0.1 2000

打开服务端日志,看它是否把ACK_CHALLENGE回给模拟端。这一步不去真实AC环境就能快速验证打包解包逻辑,我在接入新厂商AC之前都会先这样自测一轮。从那以后我每次调Portal服务端,都强制走一遍“先抓包、再改配置、最后看回包”的流程,跟以前靠界面日志猜思路完全不一样。这套方法同样适合验证别人写的Portal服务端程序是否靠谱。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询