高渐离の屋

一个不起眼的个人小站

0%

在家用上起夜级 Wi-Fi

其实这篇文章已经完成很久了,但是直到最近才找到机会把它完善并发表出来。

WPA2/3-Enterprise 第一次接触到这种 Wi-Fi 还是在北邮,当时学校的BUPT-mobile需要同时输入账号密码,令我大感新奇。一晃多年,毕业后住进了自己的出租屋,终于有机会来自己折腾一下网络了。

企业在哪里?

作为 WPA2 标准中的一对孪生兄弟,EnterprisePersonal 采用完全相同的链路层加密方式和 4-Way Handshake 密钥协商机制。唯一的区别在于身份认证流程:

  • WPA2-PSK(Personal):AP 和所有客户端预先共享一段密码(Passphrase),双方直接用它派生成对主密钥 PMK,然后进入 4-Way Handshake。
  • WPA2-Enterprise(802.1X 模式):AP不提供任何认证流程,而是通过 EAP 协议交由 RADIUS 服务器进行身份认证。认证通过后,RADIUS 再派生属于本次会话的 PMK ,用于后续加密。 通过中心化的 RADIUS 服务器针对每个用户的独立账号进行认证,针对每个用户生成独立的 PMK;当发生密码泄漏等安全事故时可以只单一吊销指定用户的凭据。同时 RADIUS 还可以提供授权,计费等功能,用于更多复杂的场景。
    802.1X最初是为有线网络设计的,因此在企业级交换机上,它也用于有线网络认证。在企业管理的场景中,可以根据认证信息的不同把同一交换机上的用户接入到不同的 VLAN 网络(研发,内部,访客)。

认证流程

WPA2-Enterprise 的完整接入过程可以拆成 4 个阶段:

  1. 射频关联:Client 与 AP 建立射频层连接,形成了一个临时的通道,此时AP 的”受控端口”处于未授权状态,只放行 EAPOL 帧。
  2. EAP 认证:EAP Client(运行在客户端上)与 RAIDUS 服务器沟通,完成身份验证。
  3. Handshake: 使用上一步拿到的 PMK 进行握手
  4. 正常通信

可以看到,在整个 EAP 流程中,AP 不参与任何认证相关的工作,它只负责把 Client 侧的 EAP 消息封装进 RADIUS 报文送给 Server,把 Server 侧的 EAP 消息剥壳后转交 Client。

因此你甚至可以直接绕过 AP 直接与 RADIUS Server沟通(这在调试的时候非常有用):

1
eapol_test -ctest.conf -a127.0.0.1 -p1812 -ssecret -r1

EAP 有多种认证方法,常见的有 EAP-TLS、EAP-TTLS、PEAP 等。接下来介绍一下他们的区别:

  • EAP-TLS:基于证书的认证方式,客户端和服务器都需要安装证书。在大企业中其实用的很多,但是在家里面部署就稍微有些繁琐了。部署非常方便,直接在 FreeRADIUS的cert目录下运行make即可。缺点是需要针对每个客户端都生成证书,证书还有过期时间,管理起来比较麻烦。客户端还需要手动安装CA证书和客户端证书,容易被家里人屌。
  • EAP-TTLS:只有服务端需要使用证书,随后在 TLS 隧道中使用用户名密码进行认证。证书可以直接复用 Web 的SSL证书,因此客户端甚至不需要安装CA证书,使用起来最简单。缺点是部署起来非常复杂,而且文档缺乏,需要自己摸索。北邮的 BUPT-mobile 就是使用的这种方式。
  • PEAP:微软特供版EAP-TTLS,不支持明文密码,据说 Windows 上可以开箱即用,我没跑起来过。

介绍完背景知识,我们就可以来搭建一下自己的企业级 Wi-Fi 了。我这里选择的方案是 EAP-TTLS + FreeRADIUS + rest.

安装 FreeRADIUS

FreeRADIUS是一款基于BSD协议的开源RADIUS服务器,主要用于宽带账户认证、授权和计费(AAA)管理。它支持多种认证方式,包括 PAP、CHAP、MS-CHAPv1/v2、EAP 等,并且可以与多种数据库(如 MySQL、PostgreSQL、LDAP 等)集成,以实现用户信息的存储和管理。

都什么年代了,还在传统安装,直接 docker 启动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FreeRADIUS:
container_name: radius_FreeRADIUS
depends_on:
radius_mgnt:
condition: service_healthy
image: FreeRADIUS/FreeRADIUS-server:latest-alpine
networks:
- default
ports:
- 1812:1812/udp
- 1813:1813/udp
restart: unless-stopped
volumes:
- /mnt/docker/FreeRADIUS_rest:/etc/raddb # 配置文件
- /private/certs:/etc/raddb/certs #证书

配置 FreeRADIUS

这部分是最复杂的部分,网上的资料很少,踩了无数的坑。其中还涉及到2.x和3.x版本的差异,很多教程都是过时的。我们这里主要涉及到修改5个文件:clients.conf,mods-enabled/eap,mods-enabled/rest,sites-enabled/default,sites-enabled/inner-tunnel

clients.conf

这个文件最简单,主要是配置允许访问 RADIUS 的客户端设备,也就是我们的AP。默认只开启了 localhost,我们直接在文件尾部追加一行就行:

1
2
3
4
client gaojianli {
ipaddr = 192.168.1.0/24
secret = <YOUR_SECRET>
}

模块配置

FreeRADIUS 作为一个企业级软件,支持各种五花八门的认证方式。从最简单的纯密码,到花里胡哨的 yubikey 都支持,这些模块的配置文件都放置在了 mods-available 目录下,默认是未启用状态。

虽然 FreeRADIUS 有名为 mysqlpython 的模块,可以直接去查询数据库。但是其用户体验还是太超前了,配置起来也极其恶心。好在 FreeRADIUS 还有一个 rest 模块,可以把认证流程外包给外部HTTP接口,我们就可以自己写认证程序了!

我们在启动一个模块之前需要先把它添加到 mods-enabled 目录下:

1
2
ln -s ../mods-available/eap mods-enabled/
ln -s ../mods-available/rest mods-enabled/

eap

这个模块是 FreeRADIUS 的核心模块,负责处理 EAP 协议的认证。

这个文件默认很长,一大堆杂七杂八的东西,一点用没有,还容易漏了导致配错。我们只保留 tls 相关的配置,其他的都删掉。同样都是配置 TLS ,因此其配置内容和 nginx 差不多,就是配置一下证书路径这些信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
eap eap-home { // 这个名字需要记一下
default_eap_type = ttls // 默认使用 EAP-TTLS
timer_expire = 120
ignore_unknown_eap_types = no
cisco_accounting_username_bug = no
max_sessions = ${max_requests}

tls-config tls-common {
private_key_file = ${certdir}/xxx.gaojianli.me/xxx.gaojianli.me.key // 私钥路径
certificate_file = ${certdir}/xxx.gaojianli.me/xxx.gaojianli.me.full.cer // 证书路径
ca_file = /etc/ssl/cert.pem // 我证书是合法证书,所以直接用系统CA就行
dh_file = ${certdir}/dh
ca_path = ${cadir}
cipher_list = "DEFAULT"
cipher_server_preference = no
ecdh_curve = "prime256v1"

cache {
enable = no
}

ocsp {
enable = no
override_cert_url = yes
url = "http://127.0.0.1/ocsp/"
}
}

ttls {
tls = tls-common
default_eap_type = pap
copy_request_to_tunnel = yes
use_tunneled_reply = yes
virtual_server = "inner-tunnel"
}
}

rest

这个模块的文档是少之又少,官方文档只写了一个简单的配置示例,其他的都需要自己摸索。配套接口的开发更是一步三个坑,一度把我弄到放弃。最后还是直接找 BYR 的同学抄了作业,才跑通了流程,核心配置文件其实就这两节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
authorize {
uri = "${..connect_uri}/api/v1/radius/authorize"
method = 'post'
body = 'json'
data = '{"username": "%{User-Name}"}'
tls = ${..tls}
}

authenticate {
uri = "${..connect_uri}/api/v1/radius/auth"
update control{
&REST-HTTP-Header += "X-Device-MAC: %{Calling-Station-Id}"
&REST-HTTP-Header += "X-NAS-IP: %{NAS-IP-Address}"
&REST-HTTP-Header += "X-Target-SSID: %{Called-Station-Id}"
}
method = 'post'
body = 'json'
data = '{"username": "%{User-Name}","password": "%{User-Password}"}'
tls = ${..tls}
}

配置文件在有现成参考的情况下还是挺直观的,URL、参数什么的都一目了然。其实就是记住一个点,只有 authenticate 才需要密码,authorize 只需要用户名就行。另外可以通过 header 传递一些额外信息给后端,比如客户端的 MAC 地址,AP 的 IP 地址,连接的 SSID 等等。

另外如果你的后端是 HTTPS 的话,记得在 rest 模块的 tls 配置中把 CA 证书路径配置正确,否则会报错。

站点配置

sites-enabled这个和 nginx 的站点配置也差不多,也是需要从 sites-available 目录下启用的。由于前面提到的 EAP 流程中,真实认证是在隧道中进行的,因此我们需要配置两个站点,一个是外部站点,一个是内网隧道站点。在我这里 home 站点就是外部站点,inner-tunnel 就是内网隧道站点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
server home {
listen {
type = auth
ipaddr = *
port = 1812 // 监听地址
limit {
max_connections = 16
lifetime = 0
idle_timeout = 30
}
}
authorize {
eap-home {
ok = return
}
rest
}
authenticate {
Auth-Type eap-home {
eap-home
}
}
}

这里 authorize 段的配置文件非常反直觉,因此值得拿出来好好讲讲:

  1. 首先是 eap-home ,这是在前面 eap 模块中配置实例名,代表了一套证书的配置。RADIUS 服务器会根据对应的 eap 配置文件中的逻辑,执行 EAP 流程。
  2. 流程返回 ok后,EAP根据 ok = return 的配置,直接返回到 authorize 段的上一级,也就是跳过了后续的 rest 模块。
  3. 如果是非 EAP 流程,比如内部使用 radtest 进行测试的场景,则直接 fallback 到 rest 模块,去调用后端的 HTTP 接口进行认证。

这里就有一个大坑:在步骤 2 中隐含了一个逻辑,eap模块在完成认证的同时会设置 Auth-Type = eap-home,这个是后续 authenticate 段的关键,RADIUS 服务器会根据这个值去调用对应的认证模块进行认证。

接下来的 authenticate 就还比较直观了,Auth-Type eap-home 代表了前面设置的认证类型,如果命中这个类型,则会调用 eap-home 模块进行认证(也就是我们在 eap 模块中配置的 URL)。

这个配置文件总体来看最恶心的地方就是省略了 if-else 语句,同时又是 DSL 又是半脚本,导致逻辑非常隐晦;同时 Auth-Typeeap 模块的实例名是相同的,进一步增加了排查的难度。整个流程总结下来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Access-Request 到达

进入 server home 的 authorize { } 段

按顺序执行第 1 条:eap-home 模块

eap-home 模块内部逻辑:
1. 检查请求里有没有 EAP-Message 属性
2. 有 → 执行 update control { Auth-Type := eap-home } ← ★ 就在这里设置
└ 返回码 = updated(或 ok)
3. 没有 → 什么都不做,返回码 = noop

遇到 ok = return,立即从 authorize 返回

FreeRADIUS 查 control:Auth-Type,值为 eap-home

进入 authenticate { } 段

匹配到 Auth-Type eap-home { eap-home },调用 eap-home 模块做真正的 EAP 处理

比起home站点的配置,inner-tunnel站点就简单多了,主要是处理 EAP-TTLS 隧道中的 PAP 认证,具体解释可以看注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
server inner-tunnel {
listen {
ipaddr = 127.0.0.1 // 只监听本地回环地址
port = 18120
type = auth
}
authorize {
if (&User-Password) { // 只有在隧道中才会有密码
update control {
&REST-HTTP-Header += "X-Device-MAC: %{Calling-Station-Id}"
&REST-HTTP-Header += "X-NAS-IP: %{NAS-IP-Address}"
&REST-HTTP-Header += "X-Target-SSID: %{Called-Station-Id}"
Auth-Type := rest // 强制制定 Auth-Type 为 rest
}
}
return
}

authenticate {
rest // 前面已经指定了,所以直接调用 rest 模块就行
}
}

后端开发

这部分没啥好讲的,就是简单的CRUB而已,需要实现 authorizeauthenticate 两个接口。

虽然文档说返回值可以是JSON,还可以自定义写入 RADIUS 的属性,但是我实测没成功。最后只能返回 Status Code 200 或者 403,其他设置任何内容都会跑不通,body必须留空,真是奇怪。

如果读者知道为什么,欢迎在评论区交流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
func (rc *RadiusController) Authenticate(ctx context.Context, c *app.RequestContext) {
var req RadiusAuthRequest
if err := c.BindAndValidate(&req); err != nil {
c.JSON(consts.StatusBadRequest, RadiusAuthResponse{
StatusCode: 400,
Reply: "Invalid request format",
})
return
}

user, err := database.DAO.User.GetByUsernameForAuth(ctx, req.Username)
if err != nil {
c.JSON(consts.StatusNotFound, RadiusAuthResponse{
StatusCode: 404,
Reply: "Authentication failed: user not found or disabled",
})
return
}

if !user.CheckPassword(req.Password) {
// 记录密码错误的认证日志
authLog := &models.AuthLog{
Username: req.Username,
AuthType: "authenticate",
Success: false,
IPAddress: string(c.GetHeader("X-NAS-IP")),
UserAgent: string(c.UserAgent()),
DeviceMAC: string(c.GetHeader("X-Device-MAC")),
TargetSSID: string(c.GetHeader("X-Target-SSID")),
CreatedAt: time.Now(),
}

// 异步记录日志,不影响响应速度
go func() {
database.DAO.AuthLog.Create(context.Background(), authLog)
}()

c.JSON(consts.StatusForbidden, RadiusAuthResponse{
StatusCode: 403,
Reply: "Authentication failed: invalid password",
})
return
}

// 记录认证成功的日志
authLog := &models.AuthLog{
Username: user.Username,
AuthType: "authenticate",
Success: true,
IPAddress: string(c.GetHeader("X-NAS-IP")),
UserAgent: string(c.UserAgent()),
DeviceMAC: string(c.GetHeader("X-Device-MAC")),
TargetSSID: string(c.GetHeader("X-Target-SSID")),
CreatedAt: time.Now(),
}

// 异步记录日志,不影响响应速度
go func() {
database.DAO.AuthLog.Create(context.Background(), authLog)
}()

c.JSON(consts.StatusOK, RadiusAuthResponse{})
}

func (rc *RadiusController) Authorize(ctx context.Context, c *app.RequestContext) {
var req struct {
Username string `json:"username" binding:"required"`
}

if err := c.BindAndValidate(&req); err != nil {
c.JSON(consts.StatusBadRequest, RadiusAuthorizeResponse{
Reply: "Invalid request format",
})
return
}

_, err := database.DAO.User.GetByUsernameForAuth(ctx, req.Username)
success := err == nil

if !success {
c.JSON(consts.StatusForbidden, RadiusAuthorizeResponse{
Reply: "User not found, disabled, or banned",
})
return
}

c.JSON(consts.StatusOK, RadiusAuthorizeResponse{})
}

除了JSON Body可以读取账号密码外,还可以通过前面注入的 header 来获取客户端的 MAC 地址,AP 的 IP 地址,连接的 SSID 等等信息用于记录访问情况。

测试

后端开发完成部署后,我们先以调试模式启动 FreeRADIUS,看看有没有报错:

1
radiusd -X

如果配置文件有问题,这时应该会报错;如果看到类似这样的输出,就代表 FreeRADIUS 已经准备就绪了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
radiusd: #### Opening IP addresses and Ports ####
listen {
type = "auth"
ipaddr = *
port = 1812
limit {
max_connections = 16
lifetime = 0
idle_timeout = 30
}
}
listen {
type = "auth"
ipaddr = 127.0.0.1
port = 18120
}
Listening on auth address * port 1812 bound to server home
Listening on auth address 127.0.0.1 port 18120 bound to server inner-tunnel
Listening on proxy address * port 38071
All secret information will be replaced with the string '<<secret>>'
To see the contents of passwords, set `suppress_secrets=no` in the main configuration file.
Ready to process requests

接下来我们就可以用 eapol_test 来测试一下了:

1
2
3
4
5
6
7
8
9
10
11
cat > ttls.conf <<EOF
network={
key_mgmt=WPA-EAP
eap=TTLS
identity="test"
password="testPwd123"
phase2="auth=PAP"
}
EOF

docker run --rm -it --network=host -v $PWD:/supplicant rkolaja/eapol_test -c ttls.conf -a localhost -p 1812 -s testing123

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
EAPOL: Received EAP-Packet frame
EAPOL: SUPP_BE entering state REQUEST
EAPOL: getSuppRsp
EAP: EAP entering state RECEIVED
EAP: Received EAP-Success
EAP: Status notification: completion (param=success)
EAP: EAP entering state SUCCESS
CTRL-EVENT-EAP-SUCCESS EAP authentication completed successfully
EAPOL: IEEE 802.1X for plaintext connection; no EAPOL-Key frames required
WPA: EAPOL processing complete
Cancelling authentication timeout
State: DISCONNECTED -> COMPLETED
EAPOL: SUPP_PAE entering state AUTHENTICATED
EAPOL: SUPP_BE entering state RECEIVE
EAPOL: SUPP_BE entering state SUCCESS
EAPOL: SUPP_BE entering state IDLE
eapol_sm_cb: result=1
EAPOL: Successfully fetched key (len=32)
PMK from EAPOL - hexdump(len=32): ab 24 31 2a 63 65 08 95 8d 6b a0 55 a3 9b 0b d6 89 02 80 35 3d 1a e4 21 54 4a 51 bb 1c a6 07 a0
No EAP-Key-Name received from server
WPA: Clear old PMK and PTK
EAP: deinitialize previously used EAP method (21, TTLS) at EAP deinit
ENGINE: engine deinit
MPPE keys OK: 1 mismatch: 0
SUCCESS

同时日志出现 Access-Accept 便说明认证成功:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
(5) rest: Adding reply:REST-HTTP-Status-Code = "200"
rlm_rest (rest): Released connection (0)
Need more connections to reach 10 spares
rlm_rest (rest): Opening additional connection (5), 1 of 27 pending slots used
rlm_rest (rest): Connecting to "http://radius_mgnt:8080"
rlm_rest (rest): Closing expired connection (4) - Hit idle_timeout limit
rlm_rest (rest): Closing expired connection (3) - Hit idle_timeout limit
rlm_rest (rest): Closing expired connection (2) - Hit idle_timeout limit
rlm_rest (rest): You probably need to lower "min"
rlm_rest (rest): Closing expired connection (1) - Hit idle_timeout limit
(5) [rest] = ok
(5) } # authenticate = ok
(5) } # server inner-tunnel
(5) Virtual server sending reply
(5) REST-HTTP-Status-Code = 200
(5) eap_ttls: Got tunneled Access-Accept
(5) eap-home: Sending EAP Success (code 3) ID 175 length 4
(5) eap-home: Freeing handler
(5) [eap-home] = ok
(5) } # Auth-Type eap-home = ok
(5) session-state: Discarding attributes for server home
(5) Sent Access-Accept Id 5 from 172.16.2.4:1812 to 192.168.1.1:38284 length 164
(5) MS-MPPE-Recv-Key = <<< secret >>>
(5) MS-MPPE-Send-Key = <<< secret >>>
(5) EAP-Message = 0x03af0004
(5) Message-Authenticator = 0x00000000000000000000000000000000
(5) User-Name = "tv"
(5) Finished request

部署

最后我是让GPT帮忙写了一整套前后端,代码开源在这里:https://github.com/Gaojianli/Radius-Manager ,支持了多用户管理和日志功能,可以直接拿去用:
登录界面
主界面
用户管理
授权日志

docker-compose 部署文件仅供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
services:
freeradius:
container_name: radius_freeradius
depends_on:
radius_mgnt:
condition: service_healthy
image: freeradius/freeradius-server:latest-alpine
networks:
- default
ports:
- 1812:1812/udp
- 1813:1813/udp
restart: unless-stopped
volumes:
- /mnt/docker/freeradius_rest:/etc/raddb
- /etc/certs:/etc/raddb/certs:ro
mariadb:
container_name: radius_mariadb
environment:
MARIADB_DATABASE: radius_mgnt
MARIADB_PASSWORD: radius123
MARIADB_ROOT_PASSWORD: radius123
MARIADB_USER: radius
expose:
- "3306"
image: mariadb:lts-ubi9
networks:
- default
restart: unless-stopped
volumes:
- mariadb_data:/var/lib/mysql
radius_mgnt:
container_name: radius_mgnt_app
depends_on:
- mariadb
environment:
DB_HOST: mariadb
DB_NAME: radius_mgnt
DB_PASSWORD: radius123
DB_PORT: 3306
DB_USER: radius
DEFAULT_ADMIN_EMAIL: admin@gaojianli.me
DEFAULT_ADMIN_PASSWORD: 1145141919810yajuu
DEFAULT_ADMIN_USER: admin
JWT_SECRET: 1145141919810yajuu
SERVER_PORT: ":8080"
healthcheck:
interval: 10s
retries: 5
start_period: 20s
test:
- CMD
- nc
- "-z"
- localhost
- "8080"
timeout: 5s
image: ghcr.io/gaojianli/radius-manager:latest
networks:
- default
restart: unless-stopped
version: "3.8"
volumes:
mariadb_data: {}

最后在路由器上配置好加密方式和 RADIUS 服务器的 IP 地址和端口,恭喜你,你已经拥有了自己的有病企业级 Wi-Fi 了!

1
2
3
option encryption 'wpa2'
option auth_server '192.168.1.1'
option auth_secret 'your_secret_key'

连接效果