RADIUS(Remote Authentication Dial In User Service)是一种基于UDP的客户端/服务器协议,广泛用于拨号接入、Wi-Fi认证(802.1X)、VPN网关等场景,是AAA框架——即认证(Authentication)、授权(Authorization)和记账(Accounting)——最经典的落地协议之一。虽然生产环境通常使用FreeRADIUS这样的成熟软件,但用Ruby亲手实现一个简易的RADIUS服务器,是理解协议细节最直接的方式。本文将从协议结构讲起,逐步完成报文解析、PAP与CHAP认证、记账处理以及响应构造的完整实现。

一、RADIUS协议基础与报文结构
RADIUS报文封装在UDP数据报中,官方推荐认证端口为1812,记账端口为1813。整个报文由固定长度的20字节头部和若干属性(Attribute)组成。头部包含以下字段:1字节Code(报文类型)、1字节Identifier(报文标识,用于配对请求与响应)、2字节Length(报文总长度)、16字节Authenticator(认证器)。
Code字段决定了报文的用途,常见的取值包括:1表示Access-Request(接入请求)、2表示Access-Accept(认证通过)、3表示Access-Reject(认证拒绝)、11表示Access-Challenge(质询)、4表示Accounting-Request(记账请求)、5表示Accounting-Response(记账响应)。头部之后是TLV格式的属性列表,每个属性由1字节Type、1字节Length和Length-2字节的数据构成。
Authenticator在请求和响应中的含义不同。对于Access-Request,它是16字节的随机数(Request Authenticator);对于响应报文,该字段被替换为Response Authenticator,其值是对响应报文与请求Authenticator以及共享密钥拼接后做MD5运算的结果,客户端据此校验响应的合法性。对于记账请求,Authenticator则是报文内容与密钥拼接后的MD5摘要。理解这一点是实现校验和构造响应的关键。
二、用Ruby解析请求报文
首先需要一个UDP服务器来接收报文,Ruby标准库中的socket就能胜任。收到数据后,用String#unpack按格式拆出头部字段,再循环解析TLV属性,把结果存入哈希表方便后续查询。
require 'socket'
class RadiusServer
CODE_ACCESS_REQUEST = 1
CODE_ACCOUNTING_REQUEST = 4
def initialize(port = 1812, secret = 'testing123')
@secret = secret
@socket = UDPSocket.new
@socket.bind('0.0.0.0', port)
end
def parse_packet(data)
code, id, length, auth = data.unpack('CCna16')
attrs = {}
offset = 20
while offset < length
type, len = data[offset, 2].unpack('CC')
break if len.nil? || len < 2
value = data[offset + 2, len - 2]
attrs[type] = value
offset += len
end
{ code: code, id: id, auth: auth, attrs: attrs }
end
def run
loop do
data, addr = @socket.recvfrom(4096)
pkt = parse_packet(data)
handle(pkt, data, addr)
end
end
end
上面的代码完成了报文骨架的解析。注意unpack('CCna16')中,C对应1字节无符号整数,n对应16位网络字节序整数,a16对应16字节的原始二进制串。属性解析时务必检查Length字段是否小于2,否则畸形报文会导致死循环,这是处理不可信网络输入时的基本防御手段。
常用的属性类型可以定义常量映射,例如Type 1是User-Name,Type 2是User-Password,Type 4是NAS-IP-Address,Type 5是NAS-Port,Type 60是CHAP-Password。为了调试方便,建议把属性类型号翻译成可读名称后打印日志,这样在排查问题时能快速定位是哪个属性解析出了偏差。
三、PAP与CHAP认证的实现
PAP(Password Authentication Protocol)认证中,客户端把用户密码加密后放在User-Password属性里传输。加密算法是:先将密码补零到16字节的整数倍并按16字节分块,第一块与Request Authenticenticator做异或后对共享密钥做MD5,后续每一块与前一块的密文异或后再做MD5。解密即是逆过程。Ruby的digest库提供了MD5实现,代码非常简洁。
require 'digest'
def decrypt_user_password(encrypted, request_auth)
blocks = encrypted.unpack('a16' * (encrypted.bytesize / 16))
result = ''
previous = request_auth
blocks.each do |block|
md5 = Digest::MD5.digest(@secret + previous)
plain = block.bytes.zip(md5.bytes).map { |b, m| b ^ m }.pack('C*')
result << plain.gsub(/\x00+\z/, '')
previous = block
end
result
end
CHAP认证则不同,客户端发送的CHAP-Password属性包含1字节标识符加16字节的MD5摘要,摘要的计算方式是MD5(标识符 + 明文密码 + CHAP-Challenge)。CHAP-Challenge通常作为独立属性携带,若无此属性则直接使用Request Authenticator。服务器端只需按同样规则计算摘要再比对即可,密码本身不以任何形式在线路上传输,安全性优于PAP。
认证逻辑的核心就是取出用户名和密码凭据,与本地用户库比对。认证通过返回Access-Accept,失败返回Access-Reject。如果想实现二次验证(如短信验证码),可以返回Access-Challenge并在State属性中携带会话状态,客户端会带着State再次发起请求。一个简单的用户库可以用哈希表示,生产环境则应对接数据库或LDAP目录服务。
四、记账报文处理与响应构造
记账请求的Code为4,通过Acct-Status-Type属性(Type 40)区分Start(1)、Stop(2)、Interim-Update(3)等事件。NAS设备在用户上线时发送Start,下线时发送Stop,服务器据此可以统计在线时长、流量等信息。记账请求的Authenticator必须校验:将报文中Authenticator字段替换为16个零字节后,对整个报文与密钥的拼接结果做MD5,结果应与原始Authenticator一致,否则说明报文被篡改或密钥不匹配,应当丢弃。
响应报文的构造需要注意Identifier必须与请求一致,Response Authenticator的计算方式前文已述。下面给出完整的响应构造与主处理逻辑:
def build_response(code, id, request_auth, attrs = '')
header = [code, id, 20 + attrs.bytesize].pack('CCn')
resp_without_auth = header + "\x00" * 16 + attrs
digest = Digest::MD5.digest(resp_without_auth + request_auth + @secret)
header + digest + attrs
end
USERS = { 'alice' => 'password123' }
def handle(pkt, raw, addr)
case pkt[:code]
when 1 # Access-Request
user = pkt[:attrs][1]
pass = decrypt_user_password(pkt[:attrs][2].to_s, pkt[:auth])
ok = USERS[user] == pass
code = ok ? 2 : 3
puts "Auth #{user}: #{ok ? 'ACCEPT' : 'REJECT'}"
resp = build_response(code, pkt[:id], pkt[:auth])
when 4 # Accounting-Request
status = pkt[:attrs][40].to_s.unpack1('C')
puts "Accounting event: status=#{status}"
resp = build_response(5, pkt[:id], pkt[:auth])
else
return
end
@socket.send(resp, 0, addr[3], addr[1])
end
记账数据建议落库保存,至少记录用户名、事件类型、会话ID(属性44)、会话时长(属性46)和上下行流量(属性42与43),这样就能完整还原用户的会话轨迹。对于授权环节,可以在Access-Accept中附带Reply-Message、Session-Timeout等属性,向NAS下发策略,这就是AAA中Authorization的具体体现。
五、测试验证与常见问题排查
测试可以使用FreeRADIUS自带的radclient工具。例如发送一个PAP认证请求:把User-Name和User-Password写成字典格式通过管道传给radclient,目标指向127.0.0.1的auth服务并指定共享密钥testing123,如果一切正常,会收到Access-Accept响应。也可以用scapy构造原始报文做更精细的边界测试,例如发送畸形Length字段的报文,验证服务器的容错能力。
常见问题有几个:一是密钥不一致导致响应被客户端静默丢弃,此时服务器看似正常但客户端始终报超时,抓包对比Authenticator即可确认;二是忘记校验Length导致解析越界;三是认证端口与记账端口分开监听却共用同一套处理逻辑,建议分别创建两个UDP socket并用线程或IO.select多路复用。此外,真实环境还需考虑报文重复(RADIUS靠UDP重传,Identifier相同的请求应做幂等处理)、字典文件支持以及EAP透传等扩展。完成这个实现之后,AAA三要素在协议层面的协作就不再神秘:认证体现在Code 1到3的交互流程,授权落实为Accept报文中携带的属性,记账则由Code 4和5独立闭环完成。