一、什么是PORAL协议

Portal协议提供了这样一种方式。当用户未认证时,控制用户只能访问某些特定的网络资源。当用户需要访问互联网更多资源的时候,必须进行认证。

它不需要用户安装特定的客户端,只需要通过浏览器,当用户没有认证时,通过HTTP重定向到特定的认证页面,引导用户完成认证的过程。并在此过程中开展广告、社区服务等个性化业务。

《华为公司宽带产品Portal协议标准(V2.0)》和《中移动PORTAL协议规范》。这两种标准,在协议上是一脉相承的。

华为的V1.0标准和CMCC的标准基本一样。华为的V2.0标准是在V1.0的标准上稍微有一部分改动。但是引入了报文验证字之后,V2.0标准与V1.0标准完全不兼容。

CMCC的标准还是保持跟华为V1.0标准一致。
本文用PYTHON实现的是华为PORTAL协议V2.0,并且在华为NE40E上测试通过

二、协议交互过程

具体的交互可以参考华为公司宽带产品Portal协议标准(V2.0),(https://wenku.baidu.com/view/df6bb9ffd0f34693daef5ef7ba0d4a7302766c8b.html)这是百度文库里面的,可以免费阅读,但下载需要收费。这里只是简单的说一下:

1、用户访问互联网,BAS当发现用户访问白名单之外的内容时,会把用户的HTTP请求重定向到预先配置的PORTAL WEB服务器。

2、用户的WEB上输入帐号和密码。交点击提交。

3、PORTAL 服务器收到用户的提交后,会按照PORTAL协议跟BAS进行如下交互:

    3.1 PORTAL 服务器发送请求挑战REQ_CHALLENGE(CHAP才需要,PAP认证不需要,但本文的实现是使用CHAP的)给BAS。

    3.2 BAS根据请求报文里面的用户IP生成相应的challenge,并通过ACK_CHALLENGE发回给PORTAL 服务器

    3.3 PORTAL 服务器 接收BAS发过来的ACK_CHALLENGE,并使用该challenge,结合之前收到的用户名和密码,发送认证请求REQ_AUTH给BAS

    3.4 BAS收到用户名和密码后,把这些信息发给配置的RADIUS服务器进行认证,并通过ACK_AUTH返回认证结果给PORTAL 服务器

    3.5 PORTAL 服务器接收BAS发过来的ACK_AUTH,并发送AFF_ACK_AUTH进行确认

至此PORTAL认证完成,用户可以正常访问互联网。

三、BAS上的配置(以华为NE40E做为示例)

BAS上的配置稍微有点复杂,请参照华为数通的配置参考
//定义IP地址池
ip pool myippool bas local
 gateway 2.2.2.1 255.255.255.0
 section 0 2.2.2.2 2.2.2.254
 dns-server 114.114.114.114
 
//定义用户组,在白名单里面可以引用这个用户组
 user-group wlan
 
//定义白名单,不需要认证就可以访问的IP
acl number 6100
 rule 1 permit ip source user-group wlan destination ip-address 1.1.1.1 0 //允许用户访问PORTAL WEB服务器
 rule 1 permit ip source user-group wlan destination ip-address 114.114.114.114 0 //允许用户访问DNS
 rule 15 deny ip source user-group wlan destination ip-address 116.8.0.1 0

//定义互联网资源
acl number 6101
 rule 15 permit ip source user-group wlan
 
//定义流分类
traffic classifier wlan_pre_auth operator or
 if-match acl 6100
traffic classifier wlan_pre_auth_2 operator or
 if-match acl 6101

//定义流动作
traffic behavior http-redirect
 http-redirect
traffic behavior permit


//定义全局策略
traffic policy Global_inbound_policy
 classifier wlan_pre_auth behavior permit
 classifier wlan_pre_auth_2 behavior http-redirect

//应用全局策略
traffic-policy Global_inbound_policy inbound
#

//配置认证前域
domain pre.gl
  authentication-scheme default0  //认证前不计费,不认证
  accounting-scheme default0
  ip-pool myippool //之前配置的地址池

  qos-profile B_4M inbound //进行适当的限速
  qos-profile B_4M outbound
  user-group wlan //配置用户组,这样可以在全局策略里使用基于用户的访问列表进行访问控制
  idle-cut 10 1000 //配置空闲策略,以避免用户长期占用地址
  web-server 1.1.1.1 //配置PORTAL服务器地址
  web-server url http://1.1.1.1:8889 //这里面配置重定向的URL,这个URL的IP一定要在白名单里面,并且可以跟上面的PORTAL服务器地址不一样
  http-hostcar enable

//配置全局WEB PORTAL服务
web-auth-server 1.1.1.1 port 50100 key simple yourpass
http-reply exclude destination-ip 1.1.1.1

//配置认证后的域
//先要配置RADIUS服务器
radius-server group myradius.gl
 radius-server authentication 1.1.1.2 1812 weight 0
 radius-server accounting 1.1.1.2 1813 weight 0
 radius-server shared-key yourkey
 radius-server source interface LoopBack0
 undo radius-server user-name domain-included //带不带域名这个要看RADIUS服务器上的配置,根据自己的实际情况来定

//认证后的域
 domain mydomai.gl
  ip-pool myippool
 
  radius-server group myradius.gl
 
//配置用户侧接口,里面涉及到的认证前域,认证域和漫游域,稍微有点复杂,这里不做解释,请自行参考华为数通的配置参考
interface
 user-vlan 3691
 bas
 #
  access-type layer2-subscriber default-domain pre-authentication pre.gl authentication mydomai.gl
  nas-port-type 802.11
  roam-domain mydomai.gl
  authentication-method web
 #
#

四、具体的python代码

完整的PORTAL系统应该由三个部分组成:

1、WEB服务器,按上面的配置IP是1.1.1.1,监听的端口是TCP 8889,这个IP一定要放到白名单里面,主要用于跟用户交互,获取用户输入的用户名和密码

2、PORTAL服务,按上面的配置是1.1.1.1,监听的端口是UDP 50100,这个IP可以不放在白名单里面(上面的配置WEB和PORTAL服务是同一IP的,其实也可以不同),主要用于使用PORTAL协议跟BAS交互

3、RADIUS服务,按上面的配置是1.1.1.2,认证端口是UDP 1812,记帐端口是UDP 1813

这三个部分可以是用同一个进程实现,也可以使用三个不同的进程实现。在我自己的实现当中是使用三个不同的程序分别实现的。WEB服务器和PORTAL服务需要进行一定的交互,主要是WEB服务器要把用户IP、用户名和密码发给PORTAL服务,PORTAL服务把相应的信息打包发给BAS,并把BAS的认证结果返回给WEB服务,以便WEB服务把认证结果返回给用户。我使用了一个自定义的协议,报文处理就在SimpleMessage这个类里。而PORTAL协议的处理主要在PortalMessage这个类里面。下面的代码主要是PortalMessage这个类,实现了REQ_CHALLENGE、REQ_AUTH和AFF_ACK_AUTH以及REQ_LOGOUT这4种报文,并可以解码ACK_CHALLENGE和ACK_AUTH这两种BAS发过来的报文。基本满足了业务需求。注意以下的代码只是实现了第2部分PORTAL服务中报文协议的编码和解码,其它的事情需要自己编码处理

#-* -coding: utf8 -* -
'''
Created on 2021-6-25
PORTAL协议和自定义协议的类
@author: qinhui
'''
import random
import hashlib
md5_constructor = hashlib.md5


import struct
import binascii
import sys
import datetime
#定义portal报文类型
REQ_CHALLENGE=1
ACK_CHALLENGE=2
REQ_AUTH=3
ACK_AUTH=4
REQ_LOGOUT=5
ACK_LOGOUT=6
AFF_ACK_AUTH=7
NTF_LOGOUT=8
REQ_INFO=9
ACK_INFO=10

#定义属性类型
ATTR_UserNAME=1
ATTR_PassWord=2
ATTR_Challenge=3
ATTR_ChapPassWord=4
ATTR_TextInfo=5
ATTR_UplinkFlux=6
ATTR_DownFlux=7
ATTR_Port=8

#打印二进制字节流
def printBin(data):
    s1=binascii.b2a_hex(data).decode('utf-8')
    print s1
    #myhex=[]
    #for x in bytes(data):
    #    myhex.append(hex(ord(x)))
    #print " ".join(myhex)
    return s1

#打印日志信息
def printLog(strTitle,strMsg):
    now=datetime.datetime.now()
    strNow=now.strftime("%Y-%m-%d %H:%M:%S")
    print "%s [-] %s->%s" %(strNow,strTitle,strMsg)

#解码TLV数据
def decodeTLV(attNum,data):
    data=bytearray(data)
    #print "decodeTLV"
    #printBin(data)
    start=0
    dataLen=len(data)
    attrList=[]
    
    for _ in range(attNum):
        if dataLen-start<3:
            break
        attType=data[start]
        attLen=data[start+1]-2
        if dataLen-start<data[start+1]:
            break
        attData=data[start+2:start+2+attLen]
        attrList.append((attType,attLen,attData))
        #print (attType,attLen,attData)
        start=start+attLen+2
    return attrList

#编码TLV
def encodeTLV(attList):
    buf=bytearray()
    for attType,attLen,attData in attList:
        buf.append(attType)
        buf.append(attLen+2)
        buf=buf+attData
    return buf
        
#根据ErrCode及报文类型返回具体的错误信息   
def getErrorInfo(pktType,ErrCode):  
    errInfo=u"未知错误:%d->%d" % (pktType,ErrCode)
    if pktType==ACK_CHALLENGE:
        if ErrCode==0:
            errInfo=u"请求挑战成功"
        elif ErrCode==1:
            errInfo=u"请求挑战被拒绝"
        elif ErrCode==2:
            errInfo=u"链接已建立"
        elif ErrCode==3:
            errInfo=u"有一个用户在认证过程中,请稍后重试"
        elif ErrCode==1:
            errInfo=u"请求挑战失败,发生错误"
    elif pktType==ACK_AUTH:
        if ErrCode==0:
            errInfo=u"认证成功"
        elif ErrCode==1:
            errInfo=u"认证请求被拒绝"
        elif ErrCode==2:
            errInfo=u"链接已建立"
        elif ErrCode==3:
            errInfo=u"有一个用户在认证过程中,请稍后重试"
        elif ErrCode==1:
            errInfo=u"认证请求失败,发生错误"
    elif pktType==REQ_LOGOUT:
        if ErrCode==0:
            errInfo=u"用户下线成功"
        elif ErrCode==1:
            errInfo=u"没有收到BAS的响应报文"
    elif pktType==ACK_LOGOUT:
        if ErrCode==0:
            errInfo=u"用户下线成功"
        elif ErrCode==1:
            errInfo=u"用户下线被拒绝"
        elif ErrCode==2:
            errInfo=u"用户下线失败"
    elif pktType==ACK_INFO:
        if ErrCode==0:
            errInfo=u"处理成功"
        elif ErrCode==1:
            errInfo=u"功能不支持"
        elif ErrCode==2:
            errInfo=u"消息处理失败"  
    #以下为自定义的错误信息
    elif pktType==100:
        if ErrCode==0:
            errInfo=u"处理成功"
        elif ErrCode==1:
            errInfo=u"认证请求不完整"
        elif ErrCode==2:
            errInfo=u"消息类型没有定义"     
    return errInfo 

#处理自定义消息简单协议的类,该协议用于WEB服务跟portal服务交互
'''
协议定义如下:
第1和2字节为标志字节分别是十进制88和99
第3字节是报文类型:
1:认证请求
2:注销请求
3:操作成功
4:操作失败
第4字节为TVL数量
从第5字节开始到最后都是TLV字段
TVL的类型定义如下:
1:用户名
2:明文密码
3:用户IP
以上三个都是字符串
4:错误代码(参照getErrorInfo函数,内容有两个字节,第一个字节是pktType,第二个字节是ErrCode
'''
class SimpleMessage():

    def __init__(self,data=None):
        #print data
        if data:
            data=bytearray(data)
            self.pktType=data[2]
            self.attNum=data[3]
            self.attList=decodeTLV(self.attNum,data[4:])
        else:
            self.pktType=3
            self.attNum=0
            self.attList=[]
            
    def createPacket(self):
        header=bytearray([88,99,self.pktType,self.attNum])
        tlvBuf=encodeTLV(self.attList)
        #print "simple packet:"
        #printBin(header+tlvBuf)
        return header+tlvBuf
    
    def createRequest(self,userName,userPass,userIP):
        self.pktType=1
        self.addAttr(1, userName)
        self.addAttr(2, userPass)
        self.addAttr(3, userIP)
    
    def createSuccess(self):
        self.pktType=3
    
    def createError(self,mytype,myerrCode):
        self.pktType=4
        data=bytearray(2)
        data[0]=mytype
        data[1]=myerrCode
        self.addAttr(4, data)
            
    def getAttr(self,attType):
        for myattType,attLen,attData in self.attList: 
            if attType==myattType:
                return attData
        return None
    
    def addAttr(self,mytype,data):
        #增加属性,并对属性数量加1
        self.attList.append((mytype,len(data),data))
        self.attNum=self.attNum+1
    
    #获取当前报文的错误信息
    def getError(self):
        #print "getError"
        mydata=self.getAttr(4)
        if mydata is None:
            return "no error"
        else:
            mytype=mydata[0]
            myerror=mydata[1]
            #print mytype,myerror
            return getErrorInfo(mytype,myerror) 
#处理portal协议报文的类
class PortalMessage:
    def __init__(self,secret,data=None):
        self.attrList=[]
        self.secret=secret
        if data :
            self.decodePkt(data)
        else:
            self.Version=2
            self.type=0
            self.papChap =0
            self.rsvd =0
            self.SerialNo=0
            self.ReqIdentifier=0
            self.userIp=0
            self.userPort =0
            self.errCode=0
            self.attrNum=0
            self.attrList=[]
            self.Authenticator=self.initAuthenticator()
        
        
    #生成随机序列号    
    def getSerialNo(self):
        return random.randint(1000,65000)
    
    #初始化Authenticator为16个0
    def initAuthenticator(self):
        
        return bytearray(16)
    
    #根据ErrCode及报文类型返回具体的错误信息
    def getErrInfo(self,ErrCode):
        return getErrorInfo(self.type,ErrCode)
        
    #通过challenge计算chap_password
    def createChapPassword(self,password,challenge,reqID):
        #取reqID的低8位
        myreqID=reqID & 0b0000000011111111
        mydata=bytearray()
        mydata.append(myreqID)
        mydata=mydata+password+challenge
        #print "chap buff data:"
        #printBin(mydata)
        chap_pass=md5_constructor(mydata).digest()
        #print "chap:"
        #printBin(chap_pass)
        return chap_pass
    #创建字节流,用于在网络上发送
    def createPacket(self):
        buf=bytearray()
        buf.append(self.Version)
        buf.append(self.type)
        buf.append(self.papChap)
        buf.append(self.rsvd)
        #序列号,注意要用大端模式
        tb=struct.pack('!H',self.SerialNo)
        buf=buf+tb
        #reqID,注意要用大端模式
        tb=struct.pack('!H',self.ReqIdentifier)
        buf=buf+tb
        #用户IP,注意要用大端模式
        tb=struct.pack('!L',self.userIp)
        buf=buf+tb
        #用户端口,注意要用大端模式
        tb=struct.pack('!H',self.userPort)
        buf=buf+tb
        
        buf.append(self.errCode)
        buf.append(self.attrNum)
        buf=buf+self.Authenticator
        #属性
        for AttrType,AttrLen,AttrStr in self.attrList:
            buf.append(AttrType)
            buf.append(AttrLen+2)
            buf=buf+AttrStr
        
        #print "Packet Data buffer"
        #printBin(buf)
        return buf
    #计算Authenticator
    def createAuthenticator(self):
        buf=self.createPacket()
        buf=buf+self.secret
        self.Authenticator=md5_constructor(buf).digest()
        #print "authenticator:"
        #printBin(self.Authenticator)
        return self.Authenticator
    #创建一个新的消息报文
    def createNewMsg(self,msgType,userIP,sn,reqID):
        self.type=msgType
        
        self.userIp=userIP
        self.SerialNo=sn
        self.ReqIdentifier=reqID
    #创建挑战报文    
    def createChallenge(self,userIP):
        sn=self.getSerialNo()
        #sn=4
        self.createNewMsg(REQ_CHALLENGE,userIP,sn,0)
        #计算Authenticator
        self.createAuthenticator()
    #创建请求认证报文
    def createAuth(self,userIP,challengeID,sn,reqID,userName,userPass):
        #初始化AUTH请求报文
        self.createNewMsg(REQ_AUTH,userIP,sn,reqID)
        #先计算chap密码
        chap_pass=self.createChapPassword(userPass,challengeID,reqID)
        
        #增加chap密码属性
        self.addAttr(ATTR_ChapPassWord, chap_pass)
        #增加用户名属性
        self.addAttr(ATTR_UserNAME, userName)
        #计算Authenticator
        self.createAuthenticator()
    #创建BAS认证成功后的回复报文AFF_ACK_AUTH
    def createAFF(self,userIP,sn,reqID): 
        #初始化报文
        self.createNewMsg(AFF_ACK_AUTH,userIP,sn,reqID)
        self.createAuthenticator()
    
    #创建请求下线报文,注意文档上要求提供上线时的reqID,但实际上好像用0即可
    def createLogout(self,userIP):
        #初始化报文
        sn=self.getSerialNo()
        self.createNewMsg(REQ_LOGOUT,userIP,sn,0)
        self.createAuthenticator()
    #增加属性
    def addAttr(self,mytype,data):
        #增加属性,并对属性数量加1
        self.attrList.append((mytype,len(data),data))
        self.attrNum=self.attrNum+1
    #获取属性
    def getAttr(self,mytype):
        for attrType,attrLen,attrData in self.attrList: 
            if attrType==mytype:
                return attrData
        return None
    #解码报文
    def decodePkt(self,data):
        #data=bytearray([2,2,0,0,0,4,0,2,0xac,0x4b,0x64,0xfd,0,0,0,1,0x4e,0x1f,0xf4,0xeb,0x21,0x57,0x50,0xbc,0x1d,0x4a,0xa4,0xe4,0x8b,0x25,0x76,0x11,3,0x12,0xbb,0x0b,0xcd,0x57,0x41,0x5d,0x3d,0xb7,0xb7,0xcd,0x5b,0x39,0x3f,0xc1,0x29,0xe3])
        self.Version,self.type,self.papChap,self.rsvd,self.SerialNo,\
        self.ReqIdentifier,self.userIp,self.userPort,self.errCode,self.attrNum \
        =struct.unpack('!BBBBHHLHBB',data[0:16])
        self.Authenticator=data[16:32]
        self.decodeAttr(data[32:])
        #self.userIp=
        #print self.Authenticator
        #print self.attrList
        #printBin(self.attrList[0][2])
    
    def decodeAttr(self,data):
        self.attrList=decodeTLV(self.attrNum,data)
        #print self.attrList
        #return 
        #while(1):
        #    if len(data)==0:
        #        break
        #    mytype=data[0]
        #    mylen=data[1]-2
        #    myvalue=data[2:data[1]]
        #    self.attrList.append((mytype,mylen,myvalue))
        #    data=data[data[1]:]

def test():
    #log.startLogging(sys.stdout, 0)
    secret='1234567890123456'
    userIP='172.75.100.253'
    userName='web@default0'
    userPass='123456'
    myip=IPy.IP(userIP)
    userIP=myip.int()
    #第一步,发送请求挑战报文
    print u"第一步,发送请求挑战报文"
    msg=PortalMessage(secret)
    msg.createChallenge(userIP)
    msg.createPacket()
    #data= msg.createAuthenticator()
    #printBin(data)
    #第二步解码BAS发送回来的挑战响应报文,并获取一些关键字段的值,data是我模拟的BAS发过来的报文
    print u"第二步解码BAS发送回来的挑战响应报文,并获取一些关键字段的值"
    data=bytearray([2,2,0,0,0,4,0,2,0xac,0x4b,0x64,0xfd,0,0,0,1,0x4e,0x1f,0xf4,0xeb,0x21,0x57,0x50,0xbc,0x1d,0x4a,0xa4,0xe4,0x8b,0x25,0x76,0x11,3,0x12,0xbb,0x0b,0xcd,0x57,0x41,0x5d,0x3d,0xb7,0xb7,0xcd,0x5b,0x39,0x3f,0xc1,0x29,0xe3])
    msg=PortalMessage(secret,data)
    challengeID=msg.getAttr(ATTR_Challenge)
    reqID=msg.ReqIdentifier
    sn=msg.SerialNo
    #第三步发送认证请求报文
    print u'第三步发送认证请求报文'
    msg_auth=PortalMessage(secret)
    msg_auth.createAuth(userIP,challengeID,sn,reqID,userName,userPass)
    msg_auth.createPacket()
    #第四步解码BAS发送的认证响应报文
    print u'第四步解码BAS发送的认证响应报文'
    data=bytearray([2,4,0,0,0,4,0,2,0xac,0x4b,0x64,0xfd,0,0,0,0,0xa2,0xf6,0x12,8,0x97,0xfc,0x14,0x3d,0x29,0xac,0xcf,0xb5,0x58,0x2e,0x8b,0x89])
    msg_authact=PortalMessage(secret,data)
    print "ErrCode:"
    print msg_authact.errCode
    reqID= msg_authact.ReqIdentifier
    sn= msg_authact.SerialNo
    #第五步发送响应认证成功报文AFF_ACK_AUTH
    print u'第五步发送响应认证成功报文AFF_ACK_AUTH'
    msg_aff=PortalMessage(secret)
    msg_aff.createAFF(userIP, sn, reqID)
    msg_aff.createPacket()
    
    #第六步发送下线报文
    print u'第六步发送下线报文'
    msg_logout_req=PortalMessage(secret)
    msg_logout_req.createLogout(userIP)
    msg_logout_req.createPacket()

if __name__ == '__main__':
    test()

主要是PortalMessage这个类,在test函数里面模拟了数据的收发过程createPacket()这个函数返回二进制数据流,可以直接在socket函数里面发送。data是模拟收到的BAS发过来的报文,现实中应该是从socket里recv到的数据。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐