A captive portal is a web page used to provide temporary Internet access to visitors. This guide shows how to set up a captive portal using Packet Filter (pf), Apache, and a RADIUS client.
You should have a DHCP server and DNS server in place beforehand. This tutorial assumes you are familiar with Packet Filter basics. If you do not want to redirect HTTP traffic to squid, you can NAT the traffic instead, similar to how HTTPS is handled.
For the web part we will use the Apache provided with OpenBSD (Apache 1.4), adding PHP support and the radtest client from FreeRADIUS for authentication. We will also need sudo to run pfctl.
export PKG_PATH=http://ftp.fr.openbsd.org/pub/OpenBSD/5.2/packages/amd64/
pkg_add -i php
pkg_add -i freeradius
pkg_add -i sudo
The captive portal will use two interfaces:
em0: the Internet-facing interfaceem1: the client-facing interface for portal usersFirst configure the firewall. We will use a dynamic table to store authenticated clients. (pf configuration is in /etc/pf.conf.)
The captive portal works like this:
192.168.1.1 is the portal IP and 192.168.1.0/24 is the client network).Example pf rules following good practices:
# macros & tables
localip = "192.168.1.1"
localnet = 192.168.1.0/24
outgoing_iface = em0
table <captiveportal_allow> persist
# nat & rdr
# For allowed clients (nat https and rdr http to squid)
pass out quick proto tcp from <captiveportal_allow> to any port 443 nat-to (em0)
pass in quick proto tcp from <captiveportal_allow> to any port 80 rdr-to $localip port 3128
# For other clients
pass in quick proto tcp from $localnet to any port 80 rdr-to $localip port 80
pass in quick proto tcp from $localnet to any port 443 rdr-to $localip port 443
# filtering
pass in quick proto tcp from $localnet to $localip port { 80 443 }
Allow the web user to run pfctl without a password by adding to /etc/sudoers:
www ALL=(ALL) NOPASSWD: /sbin/pfctl
Disable Apache’s chroot so it can access /dev/pf and run pfctl. Add the -u and SSL flags to /etc/rc.conf.local:
httpd_flags="-u -DSSL"
Configure Apache to redirect 403 and 404 errors to the login handler (for example openbsdcaptiveportal.php) so you can intercept requested pages:
ErrorDocument 403 /openbsdcaptiveportal.php
ErrorDocument 404 /openbsdcaptiveportal.php
Tune Apache processes and clients:
StartServers 20
MaxClients 1024
Restrict access to the portal webroot to the captive network:
<Directory "/var/www/htdocs">
Options -Indexes FollowSymLinks
Order Deny,Allow
Deny from all
Allow from 192.168.1.0/24
</Directory>
Enable SSL for the portal and point to your certificate and key:
<VirtualHost _default_:443>
SSLEngine On
SSLCertificateFile /etc/ssl/server.crt
SSLCertificateKeyFile /etc/ssl/private/server.key
</VirtualHost>
Finally, generate the certificates and configure your portal application.
openssl req -x509 -nodes -days 3650 -newkey rsa:4096 -keyout /etc/ssl/private/server.key -out /etc/ssl/server.crt
The most interesting part is the authentication workflow. First, add the following rule to /var/www/htdocs/index.html:
<meta http-equiv="refresh" content="0;URL='openbsdcaptiveportal.php'">
This redirects users who request the webroot to the captive portal. It also avoids false 403 alerts from probes such as Nagios when directory listing is disabled.
Below is a complete example of the authentication script:
<?php
function redirectIfNotOnPortal() {
$url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on' ? 'https://' : 'http://') .
$_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
if ($_SERVER['HTTP_HOST'] != '192.168.1.1') {
echo '<meta http-equiv="refresh" content="0;URL=\'http://192.168.1.1/login.php?url=' . $url . '\'">';
exit(0);
}
}
function showInfoCharter() {
$output = '<textarea disabled rows="10" cols="70">Terms of use
MyCompany
1. Definition
The acronym MC refers to MyCompany.
2. Scope
This charter defines the rules governing the use of MyCompany IT resources related to this service, in compliance with applicable law. It also describes the sanctions that may apply in the event of non-compliance and recalls the main reference texts.
It applies to every person using MyCompany resources. Failure to comply with this charter may engage the signatory\'s responsibility.
3. Conditions of access to IT resources
Access to MyCompany IT resources requires authorization and may only be used within the professional framework defined for the signatory.
Any access method to IT resources, regardless of its nature (password, certificate, and so on), is strictly personal and non-transferable. It ceases to be valid as soon as its holder no longer meets the allocation criteria. In the event of loss or theft, the user must contact their IT representative.
It is strictly forbidden to deploy equipment or software that could interfere with the proper operation of MyCompany IT resources.
4. Proper use of computing resources
The signatory agrees not to intentionally perform operations that could, in particular, have the following consequences:
- stealing or using another user\'s means of access
- concealing their real identity or impersonating a third party
- intercepting any communication between third parties
- altering data exchanged between third parties
- accessing, deleting, or modifying a third party\'s data without authorization
- infringing on a third party\'s privacy
- interrupting or altering the normal operation of the network or connected systems
- bypassing the access controls and restrictions enforced on the network or connected systems
- reproducing, representing, or distributing works protected by copyright
5. Management of networks and IT systems
The signatory is informed and expressly accepts that MyCompany IT may monitor proper use of IT resources, including access logs retained for up to one year, which may contain private or confidential information.
The signatory also accepts that the IT department may take emergency measures, including temporary limitation or interruption of part or all of MyCompany networks and services, in order to preserve security in the event of a serious incident or breach.
6. Sanctions
In the event of a confirmed breach of this charter, the IT department reserves the right to immediately suspend, for an indefinite duration, part or all access to MyCompany IT resources. Depending on the nature of the breach, disciplinary and/or criminal proceedings may also follow.
7. Legal framework
- French law 78-17 of 6 January 1978 relating to data processing, data files, and freedoms
- Articles L335-2 and L335-3 of the French Intellectual Property Code
- French law 2006-961 of 1 August 2006 on copyright and related rights in the information society
- French law 2004-575 of 21 June 2004 on confidence in the digital economy
- French law 2006-64 of 23 January 2006 on the fight against terrorism
8. Charter changes
The signatory is informed that this charter may be modified at any time.
</textarea>';
return $output;
}
function showLoginContent($error = '') {
$url = isset($_GET['url']) && strlen($_GET['url']) > 0 ? $_GET['url'] : '';
$output = '<div id="logf"><img id="logi" width="400px" height="224px" src="logo_secu.jpg"/>';
$output .= '<div id="desc">Welcome to the MyCompany visitor authentication portal. This portal is strictly <b>reserved for visitors</b>. Registered users listed in the directory must use the <b>MyCompany</b> Wi-Fi network.</div>';
$output .= '<form action="login.php?log=1' . (strlen($url) > 0 ? '&url=' . $url : '') . '" method="POST">';
$output .= '<h3>Terms of use</h3>' . showInfoCharter();
$output .= '<h3>Authentication</h3>' . $error;
$output .= '<center><table><tr><td style="text-align: right">Username</td><td><input type="textbox" name="username"></td></tr>';
$output .= '<tr><td style="text-align: right;">Password</td><td><input type="password" name="password"></td></tr></table></center>';
$output .= '<input type="checkbox" name="charte" />I accept the conditions of use of MyCompany IT resources<br />';
$output .= '<input type="submit" id="send" value="Log in"></form></div>';
return $output;
}
function showError($errorId) {
switch ($errorId) {
case 1:
return '<div id="err">Some fields are missing.</div>';
case 2:
return '<div id="err">Invalid username or password.</div>';
case 3:
return '<div id="err">Unable to reach the RADIUS server. Please contact IT support.</div>';
case 4:
return '<div id="err">Authentication succeeded, but you are not allowed to use this portal.</div>';
case 5:
return '<div id="err">You did not accept the charter. Access denied.</div>';
case 6:
return '<div id="err">Fatal server-side error. Service unavailable.</div>';
default:
return '';
}
}
function showStyle() {
return '<style type="text/css">
body {
text-align: center;
font-size: 13px;
font-family: century gothic, verdana, sans-serif;
background-color: #FFF;
}
#err {
color: red;
width: 90%;
padding: 5px;
display: inline-block;
font-weight: bold;
margin: 15px;
}
#logf {
margin-top: 15px;
display: inline-block;
background-color: white;
border-radius: 5px;
padding: 0 25px 25px 25px;
background-image: -moz-linear-gradient(center top, #FAFAFA 0px, #DCDCDC 100%);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
width: 600px;
}
#logf input {
margin-bottom: 5px;
}
#logi {
margin-bottom: 15px;
}
#logf textarea, #desc {
text-align: left;
}
#logf #send {
margin-top: 15px;
}
#logok {
display: inline-block;
vertical-align: middle;
background-color: white;
border-radius: 5px;
padding: 25px;
background-image: -moz-linear-gradient(center top, #FAFAFA 0px, #DCDCDC 100%);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
width: 600px;
top: 50%;
margin-left: -300px;
position: fixed;
margin-top: -100px;
}
</style>';
}
function tryAuthRadius($user, $password) {
$output = array();
exec('/usr/local/bin/radtest "' . $user . '" "' . $password . '" radiuserver.my.company:1812 0 radpwd', $output);
for ($index = 0; $index < count($output); $index++) {
if (preg_match('#Access-Reject#', $output[$index])) {
return 1;
}
if (preg_match('#Access-Accept#', $output[$index])) {
return 0;
}
}
if (!preg_match('#^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$#', $_SERVER['REMOTE_ADDR'])) {
return 4;
}
return 2;
}
redirectIfNotOnPortal();
$output = '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd"><html><head>';
$output .= '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><title>MyCompany: Visitor authentication</title>';
$output .= showStyle();
$headerOutput = '';
$contentOutput = '';
if (isset($_GET['log']) && $_GET['log'] == 1) {
$username = isset($_POST['username']) && strlen($_POST['username']) > 0 ? $_POST['username'] : '';
$password = isset($_POST['password']) && strlen($_POST['password']) > 0 ? $_POST['password'] : '';
$charterAccepted = isset($_POST['charte']) && strlen($_POST['charte']) > 0 ? $_POST['charte'] : '';
if (!$username || !$password) {
$contentOutput .= showLoginContent(showError(1));
} elseif (!$charterAccepted) {
$contentOutput .= showLoginContent(showError(5));
} else {
$authError = tryAuthRadius($username, $password);
if ($authError != 0) {
switch ($authError) {
case 1:
$errorOutput = showError(2);
break;
case 2:
$errorOutput = showError(3);
break;
case 3:
$errorOutput = showError(4);
break;
case 4:
$errorOutput = showError(6);
break;
default:
$errorOutput = '';
break;
}
$contentOutput .= showLoginContent($errorOutput);
} else {
$contentOutput .= '<div id="logok">You are now authenticated and can access the Internet.<br /><br />';
if (isset($_GET['url']) && strlen($_GET['url']) > 0) {
$url = $_GET['url'];
$headerOutput = '<meta http-equiv="refresh" content="5;URL=' . $url . '">';
$contentOutput .= 'You will be redirected in 5 seconds to <a href="' . $url . '">' . $url . '</a>.</div>';
} else {
$contentOutput .= 'You can now close this page and browse the web.</div>';
}
$pfOutput = array();
exec('/usr/bin/sudo /sbin/pfctl -t captiveportal_allow -T add ' . $_SERVER['REMOTE_ADDR'], $pfOutput);
}
}
} else {
$contentOutput .= showLoginContent();
}
$output .= $headerOutput . '</head><body>' . $contentOutput . '</body></html>';
echo $output;
?>
This script displays four main elements:
It validates four things during authentication:
If the user arrived from another URL, that target is preserved and the portal redirects the user there after 5 seconds. Otherwise, the page simply tells them they can leave the portal.
If authentication succeeds, the client IP is added to the captiveportal_allow table.
The last step is to expire old entries from the table. Add the following cron rule with crontab -e:
# Captive portal
* * * * * /sbin/pfctl -t captiveportal_allow -T expire 3600 > /dev/null