pfSense 2.3 форма входа (login) и выхода (logout) (Captive Portal)

Дано: на сервере установлен pfSense 2.3 с модулем Captive Portal (кэптив портал для контроля входа пользователей в Интернет через WiFi - wifi-hotspot).
Задача: настроить собственную страницу ввода логина-пароля (или номера ваучера) взамен встроенной (login page form), а также отображать собственную страницу выхода (logout page, отсоединения сесии), а также обойти проблему всплывающего окна формы выхода (popup window, которая зачастую блокируется в браузере).

Т.к. мы хотим позволить пользователю не только вводить логин-пароль, но и иметь возможность войти в сеть по выданному ваучеру, нам необходимо было совместить обе формы входа, т.к. по умолчанию pfSense отображает только форму ввода логина.

Как загрузить свои шаблоны форм?
Делается это на странице настроек Captive портала в блоке "HTML Page Contents" (Services - Captive Portal - Имя зоны - Configuration):


Можно загрузить три шаблона форм: страница входа, страница вывода ошибки и страница выхода. Выводить отдельную страницу с ошибкой нецелесообразно, поэтому в качестве нее будет использовать ту же страницу входа.
Как видно уже предлагается базовый шаблон:
<form method="post" action="$PORTAL_ACTION$">
    <input name="auth_user" type="text">
    <input name="auth_pass" type="password">
    <input name="auth_voucher" type="text">
    <input name="redirurl" type="hidden" value="$PORTAL_REDIRURL$">
    <input name="zone" type="hidden" value="$PORTAL_ZONE$">
    <input name="accept" type="submit" value="Continue">
 </form>

Здесь важны имена полей для ввода, а также скрытые поля и заменяемые значения ($). На базе этого шаблона, не меняя имен, можно сделать любой свой шаблон.
Если необходимо использовать внешние файлы-ресурсы (рисунки, стили, скрипты), то их можно загрузить в File Manager портала (Services - Captive Portal - Имя зоны - File Manager). Нужно учесть, что у ресурсов в имени необходимо добавить префикс "captiveportal-"
Например,
captiveportal-logo.png
captiveportal-mystyle.css

Если этого не сделать, то им этот префикс будет присвоен при загрузке и в шаблоне ссылки на них перестанут работать.


Я приведу примеры двух шаблонов:
1) форма входа login.html
2) форма выхода logout.html

Форма входа login.html
В нашем случае она должна была содержать как поля ввода логина пароля, так и поле для ввода номера ваучера. Также нужно было добавить предварительную валидацию полей ввода от пустых значений и некорректного ввода (через javascript). Стили и скрипты можно описывать сразу в коде страницы.
Примечание: шаблон формы также допускает наличие php-скриптов (чем мы воспользуемся при создании формы выхода).

Итак, шаблон должен содержать следующие скрытые поля:
<input name="redirurl" type="hidden" value="$PORTAL_REDIRURL$">
<input name="zone" type="hidden" value="$PORTAL_ZONE$">

Оставляем их без изменений.
Поле для ввода логина должно называться auth_user, а поле пароля auth_pass. Поле для ввода номера ваучера - auth_voucher.
Форма должна содержать кнопку submit с именем accept (попытка отправить форму с другим именем такой кнопки не удалась, хотя не понятно почему, т.к. имя именно кнопки отправки не должно ни на что влиять).
Также, т.к. мы объединяем эту страницу со страницей вывода ошибок, то в том месте страницы, где хотелось бы показать текст ошибки, нужно вставить шаблон переменной: $PORTAL_MESSAGE$.
Т.к. заранее известно, что если логин-пароль не верны и ошибка возвращает текст "Invalid credentials specified.", то можно это "отловить" в скрипте и показать текст по-русски (см. мой шаблон ниже).
Тексты ошибок для ваучеров можно настроить непосредственно на странице параметров генерации ваучеров (Services - Captive Portal - Имя зоны - Vouchers):


Примечание: будьте осторожнее со сменой настроек ваучеров, любое изменение помимо текста ошибок (например, битность кода или ключа) сделает все сгенерированные ранее ваучеры недействительными.

Также при вводе ваучера пользователь должен согласиться с условиями использования нашей сети (поставить "галочку" "Согласен").
Код страницы входа получился следующим (для публикации убрала лишние стили, которые можно добавить самостоятельно, обязательные поля выделила желтым):

<!DOCTYPE html>
<html>
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
<title>Подключение к WiFi</title>
<script type="text/javascript">
    function getCookie(name) {
      var matches = document.cookie.match(new RegExp(
        "(?:^|; )" + name.replace(/([\.$?*|{}\(\)\[\]\\\/\+^])/g, '\\$1') + "=([^;]*)"
      ));
      return matches ? decodeURIComponent(matches[1]) : undefined;
    }

    function setCookie(name, value) {
      value = encodeURIComponent(value);
      var updatedCookie = name + "=" + value;
      var date = new Date;
      date.setDate(date.getDate() + 30);       
      updatedCookie += "; expires=" + date.toUTCString() + "; path=/";
      document.cookie = updatedCookie;
    }
   
    function CheckMail(txtLogin){   
        var email = txtLogin.value;  
        if(email != 0)
        {
            if(isValidEmailAddress(email))
            {
                txtLogin.style.backgroundColor = "#ffffff";            
            } else {
                txtLogin.style.backgroundColor = "#fff0f0";
            }
        } else {
            txtLogin.style.backgroundColor = "#ffffff";
        } 
    }
   
    function CheckRequired(txtField){   
        if(txtField.value != 0)
        {
            txtField.style.backgroundColor = "#ffffff";
            return true;
        } else {
            txtField.style.backgroundColor = "#fff0f0";
            return false;
        } 
    }
   
    function CheckBoxRequired(cbxFieldName){   
        var cbxField = document.getElementById(cbxFieldName);
        if(cbxField.checked)
        {
            return true;
        } else {
            return false;
        } 
    }
 
    function isValidEmailAddress(emailAddress) {
        var pattern = new RegExp(/^(("[\w-\s]+")|([\w-]+(?:\.[\w-]+)*)|("[\w-\s]+")([\w-]+(?:\.[\w-]+)*))(@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$)|(@\[?((25[0-5]\.|2[0-4][0-9]\.|1[0-9]{2}\.|[0-9]{1,2}\.))((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[0-9]{1,2})\.){2}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[0-9]{1,2})\]?$)/i);
        return pattern.test(emailAddress);
    }
   
    function isValidVaucher (VaucherNumber) {
        var pattern = new RegExp(/^([23456789abcdefhikmnprstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ]){11,12}$/i);
        return pattern.test(VaucherNumber);
    }
   
    function CheckForm () {
        var blnIsValid = true;
        var CookieValue = '';
        var CookieVaucherNumber = '';
        var CookieLogin = '';
        if (document.getElementById('VaucherForm').style.display!='none') {
            if (CheckBoxRequired('cbxRulesVaucher')) {
                document.getElementById('RequiredFieldVaucherRules').style.display='none';
            } else {
                document.getElementById('RequiredFieldVaucherRules').style.display='';
                blnIsValid = false;
            };
            var inputVaucher = document.getElementById("auth_voucher");
            if (!CheckRequired(inputVaucher)) {
                document.getElementById('RequiredFieldVaucher').style.display='';
                blnIsValid = false;
            } else {
                document.getElementById('RequiredFieldVaucher').style.display='none';
                if (!isValidVaucher(inputVaucher.value)) {
                    document.getElementById('ErrorFieldVaucher').style.display='';
                    blnIsValid = false;
                    inputVaucher.style.backgroundColor = "#fff0f0";
                } else {
                    document.getElementById('ErrorFieldVaucher').style.display='none';
                    inputVaucher.style.backgroundColor = "#ffffff";
                };
            };
           
            CookieValue = 'VaucherForm';
            CookieVaucherNumber = inputVaucher.value;
        }
        if (document.getElementById('LoginForm').style.display!='none') {
            var inputLogin = document.getElementById("auth_user");
            if (!CheckRequired(inputLogin)) {
                document.getElementById('RequiredFieldMail').style.display='';
                blnIsValid = false;
            } else {
                document.getElementById('RequiredFieldMail').style.display='none';
                if (isValidEmailAddress(inputLogin.value)) {
                    document.getElementById('FieldMailError').style.display='none';
                    inputLogin.style.backgroundColor = "#ffffff";                  
                } else {
                    document.getElementById('FieldMailError').style.display='';
                    blnIsValid = false;
                    inputLogin.style.backgroundColor = "#fff0f0";
                }
            }          
            if (!CheckRequired(document.getElementById("auth_pass"))) {
                document.getElementById('RequiredFieldPass').style.display='';
                blnIsValid = false;
            } else {
                document.getElementById('RequiredFieldPass').style.display='none';
            }
            CookieValue = 'LoginForm';
            CookieLogin = inputLogin.value;
        }
        if (blnIsValid) {
            setCookie ('CaptiveForm',CookieValue);
            if(CookieLogin != 0) {
                setCookie ('CaptiveLogin',CookieLogin);
            }
            if(CookieVaucherNumber != 0) {
                setCookie ('CaptiveVaucher',CookieVaucherNumber);
            }
        }
        return blnIsValid;
    }  
  </script> 
</head>
<body>
<form id="formcaptiveportal" action="$PORTAL_ACTION$" method="post">
<input name="redirurl" type="hidden" value="$PORTAL_REDIRURL$">
<input name="zone" type="hidden" value="$PORTAL_ZONE$">
<img class="logo" src="captiveportal-logo.jpg" border="0">
<div id="LoginForm">
    <h1>WiFi для сотрудников</h1>
    <p><label>Логин</label>
    <input name="auth_user" id="auth_user" type="email" onBlur="CheckMail(this);" ></p>
    <p><label>Пароль</label>
    <input name="auth_pass" id="auth_pass" type="password"></p>
    <p><a href="#" onclick="document.getElementById('VaucherForm').style.display='';
    document.getElementById('LoginForm').style.display='none';return false;">Подключение по ваучеру</a></p>
    <p> 
        <span id="RequiredFieldMail" style="color: red; display: none;">Логин не может быть пустым! </span>        
        <span id="FieldMailError" style="color: red; display: none;">Не верный формат логина (name@mycorp.ru)! </span>                 
        <span id="RequiredFieldPass" style="color: red; display: none;">Пароль не может быть пустым.</span> 
        <span id="ErrorLogin" style="color: red;"></span>
    </p>                   
    <p><input name="accept" id="btnLogin" onclick='return CheckForm()' type="submit" value="Подключиться"></p>
    </footer>
</div>

<div id="VaucherForm" style="display:none;">
    <h1>Подключение к WiFi по ваучеру</h1>
    <p><label>Ваучер</label>
    <input name="auth_voucher" id="auth_voucher"></p>
    <p><input name="cbxRulesVaucher" id="cbxRulesVaucher" type="checkbox">Я ознакомился и согласен с
    <a href="#" onclick="var block = document.getElementById('RulesBlock'); if (block.style.display == 'none')
    { block.style.display = '';} else {block.style.display = 'none';}; return false;">
    условиями</a> использования сети WiFi</label></p>
    <p><a href="#" onclick="document.getElementById('LoginForm').style.display='';
    document.getElementById('VaucherForm').style.display='none';return false;">Войти при помощи логина</a></p>
    <p><span id="RequiredFieldVaucher" style="color: red; display: none;"> Номер ваучера не может быть пустым. </span>
        <span id="ErrorFieldVaucher" style="color: red; display: none;"> Неверный номер ваучера. </span>
        <span id="RequiredFieldVaucherRules" style="color: red; display: none;"> Вам необходимо ознакомиться и согласиться с условиями. </span>
        <div id="RulesBlock" style="display:none;">Наши условия...</div>
        <span id="ErrorVaucher" style="color: red;"></span></p>
    <p><input name="accept" class="button" id="btnLoginVaucher" onclick='return CheckForm()' type="submit" value="Подключиться"></p>
</div>

<script type="text/javascript">
    if (getCookie ('CaptiveForm')=='VaucherForm') {
        document.getElementById('LoginForm').style.display='none';
        document.getElementById('VaucherForm').style.display = '';
    }
    var loadCookieCaptiveLogin = getCookie ('CaptiveLogin');
    if (!((loadCookieCaptiveLogin==0) | (typeof loadCookieCaptiveLogin=='undefined'))) {
        document.getElementById('auth_user').value = getCookie ('CaptiveLogin');
    }
    var loadCookieCaptiveVaucher = getCookie ('CaptiveVaucher');
    if (!((loadCookieCaptiveVaucher==0) | (typeof loadCookieCaptiveVaucher=='undefined'))) {
        document.getElementById('auth_voucher').value = loadCookieCaptiveVaucher;
    }
    var txtError = '$PORTAL_MESSAGE$';
    if (txtError == 'Invalid credentials specified.') {
        document.getElementById('ErrorLogin').innerHTML = 'Указаны неверные учетные данные.';
        document.getElementById('ErrorVaucher').innerHTML = 'Указаны неверные учетные данные.';
    } else
    {
        document.getElementById('ErrorLogin').innerHTML = txtError;
        document.getElementById('ErrorVaucher').innerHTML = txtError;
    }
</script>      
</form></body>
</html>

В итоге будет страница со ссылкой, которая переключает формы ввода логина и ваучера для удобства пользователя. Если наложить стили, форма может выглядеть примерно так:


Учтите, что также нужно предусмотреть ситуацию, когда пользователь заполнил и поле логина и поле ваучера - в этом случае перед отправкой одно из полей нужно очистить, иначе авторизация не пройдет (например, стирать скриптом не нужное поле).
Полученный html-файл можно загружать в поле Portal page contents и Auth error page contents выше указанной формы настроек.

Форма выхода logout.html

Образец формы выхода (disconnecting form) пришлось искать отдельно. Форма по умолчанию представлена в файле captiveportal.inc и представляет собой страницу, которая скриптом отображает всплывающее popup окно, а сама переходит по адресу, который первоначально запросил пользователь (redirect url).
Использовав эту страницу как образец, можно получить свою форму с нужными стилями и русским текстом.
Чтобы форма выхода (отсоединения от сессии) появлялась, нужно в настройках портала в блоке "Captive Portal Configuration" задать параметр "Logout popup window - Enable logout popup window":


Пример кода страницы с формой выхода:

<!DOCTYPE html>
<html><head>
<meta content="text/html; charset=UTF-8"
 http-equiv="Content-Type">
<title>Отсоединение от WiFi</title>
</head>
<body>
<p>Переход на страницу:
<a href="<?php echo $_POST['redirurl'] ?>">
<?php echo $_POST['redirurl'] ?></a></p>
<script type="text/javascript">
//<![CDATA[
LogoutWin = window.open('', 'Logout', 'toolbar=0,scrollbars=0,location=0,statusbar=0,menubar=0,resizable=1,width=256,height=64');
if (LogoutWin) {
    LogoutWin.document.write('<!DOCTYPE html>');
    LogoutWin.document.write('<html>');
    LogoutWin.document.write('<head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type">');
    LogoutWin.document.write('<title>Отсоединение от WiFi</title></head>');
    LogoutWin.document.write('<body>');
    LogoutWin.document.write('<div>') ;
    LogoutWin.document.write('<p>Щелкните кнокпу "Отсоединиться", чтобы отключиться от сети WiFi</p>');
    LogoutWin.document.write('<form method="POST" action="<?php echo $logouturl ?>">');
    LogoutWin.document.write('<input name="logout_id" type="hidden" value="<?php echo $sessionid ?>" />');
    LogoutWin.document.write('<input name="zone" type="hidden" value="<?php echo $cpzone ?>" />');
    LogoutWin.document.write('<input name="logout" type="submit" value="Отсоединиться" />');
    LogoutWin.document.write('</form>');
    LogoutWin.document.write('</div></body>');
    LogoutWin.document.write('</html>');
    LogoutWin.document.close();
}
document.location.href="<?php echo $_POST['redirurl'] ?>";
//]]>
</script>
</body></html>

На этой странице присутствует PHP-код для получения идентификатора сессии и других параметров.
Полученный html файл (logout.html) также загружаем в форму настроек в поле Logout page contents.
В итоге будет появляться такое окошко при входе:

Недостаток данной страницы в том, что браузеры блокируют всплывающие окна и форма выхода может вообще не отобразиться, поэтому можно изменить эту форму по своему усмотрению, убрав скрипт вывода всплывающего окна, например:
<!DOCTYPE html>
<html><head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
<title>Отсоединение от WiFi</title>
</head>
<body>
<p>Перейти на запрашиваемую страницу:
<a target="_blank" href="<?php echo $_POST['redirurl'] ?>">
<?php echo $_POST['redirurl'] ?></a></p>
<div><p>Щелкните кнокпу "Отсоединиться",
чтобы отключиться от сети WiFi</p>
<form method="POST" action="<?php echo $logouturl ?>">
<input name="logout_id" type="hidden"
value="<?php echo $sessionid ?>" />
<input name="zone" type="hidden" value="<?php echo $cpzone ?>" />
<input name="logout" type="submit" value="Отсоединиться" />
</form></div>
</body></html>


Но в этом примере пользователю придется самостоятельно нажать ссылку для перехода на первоначально запрашиваемую страницу, если это требуется.

(с) Ella S.
Если Вам понравилась статья, пожалуйста, поставьте лайк, сделайте репост или оставьте комментарий. Если у Вас есть какие-либо замечания, также пишите комментарии.

12 комментариев:

  1. Сколько ищю не могу найти четкого мануала по настройке Captive Portal на PfSense, если может кто может дайте ссылку РАБОЧЕГО манула для весии 2.3

    ОтветитьУдалить
    Ответы
    1. Тоже не встречала, приходилось все делать "методом тыка". Пока только есть статья по настройке радиус-сервера для авторизации - http://www.e-du.ru/2016/08/freeraius-ad-freebsd-samba.html

      Удалить
  2. У меня пишет все кракозябами и фото заднего плана не ставится. Почему?

    ОтветитьУдалить
    Ответы
    1. Возможно страница сделана не в той кодировке? Файл фона загружен в ресурсы с префиксом "captiveportal-"?

      Удалить
    2. я только загрузил logo.png, хотя как я понял по коду надо jpg, но он тоже не прогружается стоит крестик. А откуда брать еще 3 файла я не знаю

      Удалить
    3. не могли бы скинуть недостающие файла на shon_kostja@mail.ru или выложить их тут

      Удалить
    4. Другие файлы - это значки (спец.веб-шрифт значков fontawesome в трех вариациях). Если Вы их не используете намеренно в своей html-странице входа, то они Вам не нужны. Если Вы не знаете, используете Вы их или нет, значит они Вам также не нужны (иначе, Вы бы об этом знали).

      Удалить
    5. По поводу jpg ли png - зависит от того, что у Вас написано в HTML коде страницы входа. Покажите строку, как Вы ссылаетесь на данный файл в html. Должно быть что-то вроде <img src="captiveportal-logo.jpg"> или <img src="captiveportal-logo.png">. Данная статья писалась для версии 2.3, может у Вас другая версия и там что-то по другому...

      Удалить
  3. Элла, подскажите вот с такой проблемой. Развернул как-то captive portal на pfsense v2.1, заменил страницу логина на свою на русском языке и всё было хорошо, но тут черт дернул обновиться до версии 2.4.5 и весь русский шрифт заменился на черные ромбики со знаками вопроса внутри. Сделал чистую установку на другм компе - тоже самое. Вставил выложенные выше примеры - ни одной кириллической буквы - сплошные ромбы с вопросами... Пробовал в UTF-8 и в CP-1251 - одинаково. В чем может быть проблема? Буду очень признателен за какой-нибудь рабочий пример!

    ОтветитьУдалить
    Ответы
    1. К сожалению, не подскажу, т.к. остановились на версии 2.3 и более не обновляли, т.е. по принципу "Работает, не трожь!" :) что там сейчас с последними версиями не в курсе.

      Удалить
    2. А не остался ли случаем дистрибутив версии 2.3? Может это как раз и решит всю проблему?

      Удалить
    3. Сейчас пока нет времени искать по своим архивам. Посмотрите, вроде на гите лежат все релизы: https://github.com/pfsense/pfsense/releases?after=v2.4.2_1

      Удалить