网络准入控制里的RADIUS协议并不等同于普通的Web登录接口,它是运行在UDP 1812和1813端口上的一套二进制请求响应机制。NAS设备,例如交换机、无线控制器或VPN网关,只负责采集用户身份和接入参数,随后把这些信息按TLV格式打包成若干属性,交给中心化认证服务器判断是否放行。Node.js的事件驱动模型适合处理大量并发短连接,内置的Buffer能力又可以方便地读取和写入固定位长协议头,因此用它来实现一个RADIUS服务端是可行的。

实现过程中最关键的并不是HTTP路由设计,而是先理解协议报文,再严格控制字节偏移、属性长度和认证子计算。下面从Access-Request的解析开始,逐步搭建一个可以接收NAS请求并返回Access-Accept的UDP服务。
一、RADIUS报文结构与TLV属性解析
RADIUS报文头固定为20字节,依次为Code、Identifier、Length和Authenticator。Code表示报文类型,1对应Access-Request,2对应Access-Accept,3对应Access-Reject,4和5分别对应计费请求与响应,11则是Access-Challenge。Identifier用于匹配请求和响应,Length表示整个报文的总字节数,Authenticator在请求中通常是16字节随机数,在响应中则要根据请求认证子、响应属性和共享密钥计算出来。
头部之后紧跟若干TLV属性,每个属性包含Type、Length和Value三部分。Type占用1字节,Length占用1字节,Value长度由Length减2决定。常见属性包括User-Name对应类型1,User-Password对应类型2,NAS-IP-Address对应类型4,Reply-Message对应类型18。解析时必须严格检查每个属性的边界,否则一个异常报文就可能让服务崩溃。
const crypto = require('crypto');
function parseRadiusPacket(packet) {
if (packet.length < 20) {
throw new Error('Invalid RADIUS packet length');
}
const code = packet.readUInt8(0);
const identifier = packet.readUInt8(1);
const length = packet.readUInt16BE(2);
const authenticator = packet.slice(4, 20);
if (packet.length !== length) {
throw new Error('Packet length mismatch');
}
const attributes = [];
let offset = 20;
while (offset < length) {
const attrType = packet.readUInt8(offset);
const attrLength = packet.readUInt8(offset + 1);
if (attrLength < 2 || offset + attrLength > length) {
throw new Error('Invalid attribute length');
}
const attrValue = packet.slice(offset + 2, offset + attrLength);
attributes.push({ type: attrType, value: attrValue });
offset += attrLength;
}
return { code, identifier, length, authenticator, attributes };
}
这段解析逻辑先把头部四个字段读出来,再从第20字节开始循环读取属性。读取属性时不能只读Type和Value,还必须用Length控制步进,因为不同属性的长度差异很大。如果某个属性的Length小于2,或者偏移越过报文总长度,就说明报文损坏,应立即抛错并丢弃。
解析完成后得到的是一个结构清晰的对象,后续认证逻辑只需要遍历attributes数组,按type找到用户名和密码属性即可。这样做的好处是解析层与业务层分离,新增属性类型时不必改动整体结构,只需要在业务层增加对应类型的处理分支。
二、构造响应报文与密码隐藏算法
构造响应包同样从20字节头部开始。Code按认证结果填2或3,Identifier必须与请求一致,Length根据头部和属性总长度计算,Authenticator则不能随便填随机数。RADIUS规定响应认证子的计算公式为MD5(Code+Identifier+Length+RequestAuthenticator+Attributes+Secret),其中RequestAuthenticator来自原始请求,Secret是NAS与服务器共享的密钥。很多实现在这一步出错,把随机数直接填入响应,导致NAS端校验失败。
function buildRadiusPacket(code, identifier, authenticator, attributes) {
const header = Buffer.alloc(20);
header.writeUInt8(code, 0);
header.writeUInt8(identifier, 1);
header.writeUInt16BE(0, 2);
authenticator.copy(header, 4);
const bodyParts = [header];
let totalLength = 20;
for (const attr of attributes) {
const attrBuffer = Buffer.alloc(2 + attr.value.length);
attrBuffer.writeUInt8(attr.type, 0);
attrBuffer.writeUInt8(2 + attr.value.length, 1);
attr.value.copy(attrBuffer, 2);
bodyParts.push(attrBuffer);
totalLength += attrBuffer.length;
}
const response = Buffer.concat(bodyParts);
response.writeUInt16BE(totalLength, 2);
return response;
}
function calculateResponseAuthenticator(code, identifier, requestAuthenticator, attributes, secret) {
const attrBuffers = [];
for (const attr of attributes) {
const attrBuffer = Buffer.alloc(2 + attr.value.length);
attrBuffer.writeUInt8(attr.type, 0);
attrBuffer.writeUInt8(2 + attr.value.length, 1);
attr.value.copy(attrBuffer, 2);
attrBuffers.push(attrBuffer);
}
const totalLength = 20 + attrBuffers.reduce(function (sum, buf) {
return sum + buf.length;
}, 0);
const header = Buffer.alloc(20);
header.writeUInt8(code, 0);
header.writeUInt8(identifier, 1);
header.writeUInt16BE(totalLength, 2);
requestAuthenticator.copy(header, 4);
const hashInput = Buffer.concat([header, Buffer.concat(attrBuffers), Buffer.from(secret, 'utf8')]);
return crypto.createHash('md5').update(hashInput).digest();
}
User-Password属性不能明文传输,而要通过共享密钥和请求认证子做异或加密。算法先把密码按16字节分块,不足部分补零,然后第一块与MD5(Secret+RequestAuthenticator)做异或,后续每块与MD5(Secret+前一块密文)做异或。解密时使用同样流程反向操作。这个算法的安全性依赖共享密钥的保密性,前提是NAS和服务器都必须安全存储密钥。
响应包中的User-Password一般不需要写入,Access-Accept更常返回Session-Timeout、Idle-Timeout或Filter-Id等授权属性。只有在测试环境才建议用明文用户名密码直接比对,生产环境应接入LDAP、Active Directory或证书认证。
三、最小可运行的Node.js UDP服务端
先搭建一个监听1812端口的UDP服务,收到Access-Request后提取用户名和密码属性,进行简单校验,并根据结果返回Access-Accept或Access-Reject。这里使用Node.js内置的dgram模块,不需要额外依赖,适合理解协议过程。实际生产中可以引入radius模块或自己封装更多的属性处理。
const dgram = require('dgram');
const crypto = require('crypto');
const SHARED_SECRET = 'replace-with-a-long-shared-secret';
const server = dgram.createSocket('udp4');
server.on('message', function (msg, rinfo) {
const request = parseRadiusPacket(msg);
if (request.code !== 1) {
return;
}
const usernameAttr = request.attributes.find(function (attr) {
return attr.type === 1;
});
const passwordAttr = request.attributes.find(function (attr) {
return attr.type === 2;
});
const username = usernameAttr ? usernameAttr.value.toString('utf8') : '';
const accepted = username === 'admin' && passwordAttr;
const responseCode = accepted ? 2 : 3;
const responseAttrs = [];
responseAttrs.push({ type: 1, value: Buffer.from(username, 'utf8') });
if (!accepted) {
responseAttrs.push({ type: 18, value: Buffer.from('Authentication failed', 'utf8') });
}
const responseAuthenticator = calculateResponseAuthenticator(
responseCode,
request.identifier,
request.authenticator,
responseAttrs,
SHARED_SECRET
);
const response = buildRadiusPacket(
responseCode,
request.identifier,
responseAuthenticator,
responseAttrs
);
server.send(response, rinfo.port, rinfo.address, function (err) {
if (err) {
console.error('send failed:', err);
}
});
});
server.bind(1812, function () {
console.log('RADIUS server listening on 1812');
});
这个示例实际上只做了用户名是否存在和密码属性是否存在的校验,并没有真正解密User-Password。原因是很多NAS设备会使用CHAP或EAP方式,密码可能不在User-Password属性中,而是在CHAP-Password或EAP-Message中。如果只处理本地测试,可以进一步实现解密函数,把密码与共享密钥还原后再比对。
事件回调中的parseRadiusPacket如果抛出异常,会导致整个Node.js进程退出,因此必须在message回调内部做try catch保护,或者把解析函数设计成返回错误而不是直接抛异常。UDP数据包来源不可控,攻击者可以发送畸形包,服务端健壮性必须放在第一位。
四、对接NAS设备与EAP中继
真实网络准入控制通常不会让用户在登录页里提交密码后由Node.js直接校验,而是由交换机或无线控制器发起802.1X认证,NAS与RADIUS服务器之间使用EAP协议中继。此时Access-Request里会包含EAP-Message属性,类型79,服务器需要解析EAP包,并根据EAP类型返回Access-Challenge或Access-Accept。Message-Authenticator属性,类型80,用来保护EAP消息不被篡改。
对接时,需要在交换机或无线控制器上配置RADIUS服务器的IP地址、认证端口1812、计费端口1813以及共享密钥。共享密钥必须两端完全一致,任何一侧多出空格或换行都会导致认证失败。服务器端还要识别NAS-IP-Address、Called-Station-ID和Calling-Station-ID等属性,这些信息常用于记录用户从哪个设备和哪个端口接入。
EAP中继比简单密码认证复杂,因为请求和响应会经过多轮交互。Access-Challenge里携带EAP请求,NAS将其转发给客户端,客户端响应后再由NAS封装成新的Access-Request。Node.js服务端需要维护会话状态,把Identifier、请求认证子和EAP消息串联起来。可以使用内存Map保存状态,但多实例部署时建议改用Redis等共享存储。
五、生产环境注意事项与安全加固
RADIUS基于UDP,本身没有连接状态,也没有可靠传输保障。认证请求可能在网络拥塞时丢失,NAS会按配置重传。服务端必须把认证设计成幂等操作,同一Identifier和请求认证子的重复请求不应产生副作用。计费请求更要注意去重,尤其是计费开始和计费结束报文可能因为网络重传而重复到达,需要根据Acct-Session-Id做幂等判断。
安全方面,共享密钥不能使用短字符串或默认值。请求认证子必须由高质量的随机数生成器产生,否则攻击者可以构造重放包。生产环境应尽量启用Message-Authenticator属性,并对来自NAS的请求做来源IP校验。日志中不要记录明文密码,必要时只记录用户名、NAS标识和认证结果。
如果部署规模较大,可以考虑使用RadSec,也就是基于TCP和TLS的RADIUS扩展。Node.js可以通过TLS socket承载RADIUS报文,这样传输过程具备加密和可靠传输能力。不过很多传统NAS设备仍只支持UDP,因此在兼容现有网络设备时,1812和1813端口仍然是绕不开的基础。