Published : 2013-08-20

Captive portal

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 interface
  • em1: the client-facing interface for portal users

Packet Filter configuration

First 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:

  • If a client is not authenticated, redirect HTTP/HTTPS traffic to the local machine (here 192.168.1.1 is the portal IP and 192.168.1.0/24 is the client network).
  • If authenticated, redirect HTTP to the proxy and NAT HTTPS.

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 }

sudo configuration

Allow the web user to run pfctl without a password by adding to /etc/sudoers:

www     ALL=(ALL) NOPASSWD: /sbin/pfctl

Apache configuration

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

Authentication scripts

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:

  • The company logo and name
  • The usage charter
  • Username/password authentication
  • Acceptance of the charter

It validates four things during authentication:

  • The presence of all required fields
  • Acceptance of the charter
  • RADIUS authentication
  • The client IP address

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.

Client cleanup

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