image
Доброго времени суток. В данной заметке хочу рассказать о простой аутентификации с помощь nginx и lua-скриптов.

Подняв у себя домашний сервер на ubuntu с plex и transmission и обзаведясь доменом, через который вывел это добро в большой мир, понял Я, что было бы неплохо обзавестись единой точкой аутентификации. Тем более nginx у меня уже был установлен (даже nginx-extras, что немаловажно, поскольку там есть lua).

Собравшись с мыслями, сформулировал требования:

  • Отсутствие необходимости установки дополнительного ПО
  • Отдельная страница аутентификации
  • Сквозная аутентификация для всех сервисов за nginx
  • Хотя бы минимальная защита от перебора

Вариант с nginx basic auth не устроил по причине отсутствия защиты от перебора, вариант с nginx auth PAM вызвал у меня недоверие по причине аутентификации по логину/паролю ОС. И оба варианта не дают возможности аутентификации через свою отдельную форму.

Алгоритм аутентификации довольно прост:
image

Ну что ж, приступим.

Для начала создадим lua-скрипт с некоторым функциями, которые понадобятся нам в дальнейшем:

/etc/nginx/lua/secure.lua
-- Количество попыток для ip/32 и User-Agent
local ip_ua_max = 10

-- Количество попыток для ip/32
local ip_4_max = 50

-- Количество попыток для ip/16
local ip_3_max = 100

-- Количество попыток для ip/8
local ip_2_max = 500

-- Количество попыток для ip/0
local ip_1_max = 1000

counters = {}
counters["ip_ua"] = {}
counters["ip_4"] = {}
counters["ip_3"] = {}
counters["ip_2"] = {}
counters["ip_1"] = {}

-- Проверка числа попыток (is_cnt=false) и учёт неуспешной попытки (is_cnt=true)
function is_secure(ip, user_agent, is_cnt)
    local md5_ip_ua = ngx.md5(ip..user_agent)
    local md5_ip_4 = ngx.md5(ip)
    local md5_ip_3 = ""
    local md5_ip_2 = ""
    local md5_ip_1 = ""
    local cnt = 0
    for i in string.gmatch(ip, "%d+") do
        cnt = cnt + 1
        if cnt < 4 then
            md5_ip_3 = md5_ip_3.."."..i
        end
        if cnt < 3 then
            md5_ip_2 = md5_ip_2.."."..i
        end
        if cnt < 2 then
            md5_ip_1 = md5_ip_1.."."..i
        end
    end
    md5_ip_3 = ngx.md5(md5_ip_3)
    md5_ip_2 = ngx.md5(md5_ip_2)
    md5_ip_1 = ngx.md5(md5_ip_1)
    if is_cnt then
        -- Учитываем неуспешную попытку
        counters["ip_ua"][md5_ip_ua] = (counters["ip_ua"][md5_ip_ua] or 0) + 1
        counters["ip_4"][md5_ip_4] = (counters["ip_4"][md5_ip_4] or 0) + 1
        counters["ip_3"][md5_ip_3] = (counters["ip_3"][md5_ip_3] or 0) + 1
        counters["ip_2"][md5_ip_2] = (counters["ip_2"][md5_ip_2] or 0) + 1
        counters["ip_1"][md5_ip_1] = (counters["ip_1"][md5_ip_1] or 0) + 1
        
        -- Пишем в лог подробности неуспешной попытки
        log_file = io.open("/var/log/nginx/access.log", "a")
        log_file:write(ip.."	"..(counters["ip_ua"][md5_ip_ua] or 0).."	"..(counters["ip_4"][md5_ip_4] or 0).."	"..(counters["ip_3"][md5_ip_3] or 0).."	"..(counters["ip_2"][md5_ip_2] or 0).."	"..(counters["ip_1"][md5_ip_1] or 0).."	"..user_agent.."\n")
        log_file:close()
    else
        -- Проверяем число неуспешных попыток
        if
            (counters["ip_ua"][md5_ip_ua] or 0) > ip_ua_max or
            (counters["ip_4"][md5_ip_4] or 0) > ip_4_max or
            (counters["ip_3"][md5_ip_3] or 0) > ip_3_max or
            (counters["ip_2"][md5_ip_2] or 0) > ip_2_max or
            (counters["ip_1"][md5_ip_1] or 0) > ip_1_max
        then
            return false
        else
            return true
        end
    end
end

-- Проверка логина/пароля
-- В данном примере просто сравнение с хэшом из файла, при желании в данной функции можно реализовать проверку логина/пароля где угодно (в БД например)
function sing_in(log, pass)
    local auth_file = io.open("/etc/nginx/auth/pass","r")
    for line in io.lines("/etc/nginx/auth/pass") do
        if line == log..":"..ngx.md5(pass) then
            auth_file:close()
            return true
        end
    end
    auth_file:close()
    return false
end

-- Сохраняем функции в глобальном контейнере secure
local secure = ngx.shared.secure
secure:set("sing_in", sing_in)
secure:set("is_secure", is_secure)


Добавим инициализацию данного скрипта в глобальный конфиг nginx:

/etc/nginx/nginx.conf
• • •
http {
• • •
    # Объявляем глобальный контейнер
    lua_shared_dict secure 10m;
    # Инициализируем скрипт
    init_by_lua_file /etc/nginx/lua/secure.lua;
• • •
    include /etc/nginx/conf.d/*.conf;
}


Теперь создадим lua-скрипт для проверки cookie (шаги 2, 2.1, 3):

/etc/nginx/lua/access.lua
-- Адрес страницы аутентификации
local req_url_err = "https://auth.somedomain.ru"

-- Получаем User-Agent адрес клиента
local ua = ngx.req.get_headers()["User-Agent"]

-- Получаем токен и время из cookie
local auth_str = ngx.var.cookie_sv_auth
local auth_token = ""
local life_time = ""

if auth_str ~= nil and auth_str:find("|") ~= nil then
    local divider = auth_str:find("|")
    auth_token = auth_str:sub(0,divider-1)
    life_time = auth_str:sub(divider+1)

    -- 2. Проверяем валидность токена
    if auth_token == ngx.encode_base64(ngx.hmac_sha1("ОЧЕНЬ_СЕКРЕТНАЯ_ОЧЕНЬ_ДЛИННАЯ_СТРОКА_НАПРИМЕР_КАКОЙ-НИБУДЬ_32-УХЗНАЧНЫЙ_ХЭШ",ua.."|"..life_time)) and tonumber(life_time) >= ngx.time() then
        -- Токен валиден
        return
    end
end

-- Токен не валиден или отсутствует
-- 2.1. Сохраняем в coockie url назначения
ngx.header["Set-Cookie"] = "sv_req_url="..ngx.req.get_headers()["Host"].."; path=/; domain=.somedomain.ru; Expires="..ngx.cookie_time(ngx.time()+60*60).."; Secure; HttpOnly"

-- И возвращаем редирект на страницу аутентификации
return ngx.redirect(req_url_err)


Добавим проверку данным скриптом в конфиги внутренних сервисов:

/etc/nginx/conf.d/plex.conf
server {
    listen                    443 ssl;
    server_name               plex.somedomain.ru;

    access_by_lua_file /etc/nginx/lua/access.lua;

    location / {
        proxy_pass            http://localhost:32400;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
    ssl                       on;
• • •
}


Создадим страницу аутентификации:

/var/www/html/auth.html
<!DOCTYPE HTML>
<html>
    <head>
        <meta charset="utf-8">
        <title>somedomain</title>
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <style>
            body{
                height: 100%;
                background-color: rgb(64, 64, 64);
                text-align:center;
                align:center;
                vertical-align: middle;
            }
            form {
                display: inline-block;
                text-align: center;
                vertical-align: middle;
                position:absolute;
                top:50%;
                right:0;
                left:0;
            }
            input{
                color: rgb(0, 255, 0);
                text-align: center; 
                border: 2px solid;
                border-color: rgb(0, 255, 0);
                background-color: rgb(64, 64, 64);
            }
            ::-webkit-input-placeholder{
                color:rgb(0, 255, 0);
                text-align: center;
            }
            ::-moz-placeholder{
                color:rgb(0, 255, 0);
                text-align: center;
            }
            :-moz-placeholder{
                color:rgb(0, 255, 0);
                text-align: center;
            }
            :-ms-input-placeholder{
                color:rgb(0, 255, 0);
                text-align: center;
            }
            br{
                display: block;
                margin: 7px 0;
                line-height: 7px;
                content: " ";
            }
        </style>
    </head>
    <body>
        <form method="post">
            <input type="text" name="login" placeholder="login" autocomplete="off">
            <br>
            <input type="password" name="password" placeholder="password" autocomplete="off">
            <br>
            <input type="submit" value="sign in">
        </form>
    </body>
</html>


И добавим для неё конфиг nginx:

/etc/nginx/conf.d/auth.conf
server {
    listen                    443 ssl;
    server_name               auth.somedomain.ru;

    access_by_lua_file /etc/nginx/lua/auth_access.lua;

    location / {
        default_type    'text/html';
        root            /var/www/html/;
        index            auth.html;
        if ($request_method = POST ) {
            content_by_lua_file /etc/nginx/lua/auth.lua;
        }
    }
    ssl                       on;
• • •
}


В данном конфиге делаем проверку числа попыток аутентификации с помощью «auth_access.lua» (шаг 4, 4.2)

/etc/nginx/lua/auth_access.lua
-- Берём из глобального контейнера secure нужные нам функции
local secure = ngx.shared.secure
is_secure = secure:get("is_secure")

-- Получаем ip адрес клиента
local ip = ngx.var.remote_addr

-- Получаем User-Agent адрес клиента
local ua = ngx.req.get_headers()["User-Agent"]

-- 4. Проверка количества попыток аутентификации
if is_secure(ip,ua,false) then
    -- Проверка пройдена, удаляем невалидный токен
    ngx.header["Set-Cookie"] = {"sv_auth=; path=/; domain=.somedomain.ru; Expires="..ngx.cookie_time(ngx.time()-60).."; Secure; HttpOnly"}
    return
end

-- 4.2. Проверка не пройдена, возвращаем HTTP 403
ngx.exit(ngx.HTTP_FORBIDDEN)


И проверку логина/пароля с помощью «auth.lua» (шаг 5, 5.1, 2.2)

/etc/nginx/lua/auth.lua
-- Берём из глобального контейнера secure нужные нам функции
local secure = ngx.shared.secure
sing_in = secure:get("sing_in")
is_secure = secure:get("is_secure")

-- Получаем ip адрес клиента
local ip = ngx.var.remote_addr

-- Получаем User-Agent адрес клиента
local ua = ngx.req.get_headers()["User-Agent"]

-- Адрес страницы аутентификации
local req_url_err = "https://auth.somedomain.ru"

-- Адрес назначения из cookie или дефолтный адрес, если в cookie адреса нет
local req_url = "https://"..(ngx.var.cookie_sv_req_url or "somedomain.ru")

-- Проверяем наличие параметров POST-запроса
ngx.req.read_body()
local args, err = ngx.req.get_post_args()
if args then
    -- 4.1. Читаем из POST-запроса логин и пароль
    local log
    local pass
    for key, val in pairs(args) do
        if key == "login" then
            log = val
        elseif key == "password" then
            pass = val
        end
    end

    -- Проверяем, что логин и пароль не пустые
    if log ~= nil and pass ~= nil then
        -- 5. Проверяем валидны ли логин и пароль
        if sing_in(log, pass) then
            -- Если валидны
            -- Задаём время жизни токена (сутки)
            local life_time = ngx.time()+86400
            -- Генерируем токен
            local auth_str = ngx.encode_base64(ngx.hmac_sha1("ОЧЕНЬ_СЕКРЕТНАЯ_ОЧЕНЬ_ДЛИННАЯ_СТРОКА_НАПРИМЕР_КАКОЙ-НИБУДЬ_32-УХЗНАЧНЫЙ_ХЭШ",ua.."|"..life_time)).."|"..life_time
            
            -- 5.1. Записываем токен в cookie и удаляем оттуда url назначения
            ngx.header["Set-Cookie"] = {"sv_auth="..auth_str.."; path=/; domain=.somedomain.ru; Expires="..ngx.cookie_time(ngx.time()+60*60*24).."; Secure; HttpOnly","sv_req_url="..ngx.req.get_headers()["Host"].."; path=/; domain=.somedomain.ru; Expires="..ngx.cookie_time(ngx.time()-60).."; Secure; HttpOnly"}
            
            -- 2.2. Возвращаем редирект на страницу назначения
            return ngx.redirect(req_url)
        end
        
        -- 5.2. Если логин/пароль невалидны, учитываем это в подсчёте неуспешных попыток аутентификации
        is_secure(ip,ua,true)
    end
end

-- 3. Если логин и пароль не переданы или невалидны, возвращаем редирект на страницу аутентификации
ngx.redirect(req_url_err)


Теперь создадим файл с логином и паролем:

md5="`echo -n "PASSWORD" | md5sum`";echo -e "LOGIN"":`sed 's/^\([^ ]\+\) .*$/\1/' <<< "$md5"`" > ~/pass; sudo mv ~/pass /etc/nginx/auth/pass; sudo chown nginx:nginx /etc/nginx/auth/pass

Подставив вместо «LOGIN» логин, а вместо «PASSWORD» пароль.

Вот и всё, аутентификации реализована.

При добавлении сервисов, достаточно будет в конфигах указывать проверку по «access.lua»:

access_by_lua_file /etc/nginx/lua/access.lua;

Спасибо за внимание.

UPD 26.03.2018 (спасибо YourChief):
— Убрана функция nvl, за ненадобностью
— md5 при генерации токена заменено на HMAC
— В токен добавлено время его жизни
— md5 и HMAC используются встроенные в nginx

Комментарии (12)


  1. 0x12ee705
    24.03.2018 07:04
    -3

    Мне понравилось Ваше решение)))


  1. kolu4iy
    24.03.2018 13:42

    auth pam? Там же можно правами пользователей все разрулить… Пользователь без консоли может в nginx и ничего более, а остальные не авторизуются на nginx… Нет, ваш способ работает, но чем система проще, тем меньше точнее отказа.


    1. kolu4iy
      24.03.2018 13:44

      А на auth basic проще натянуть fail2ban или что-то подобное, например...


      1. seivgard Автор
        25.03.2018 10:03

        fail2ban — это всё таки установка доп. ПО, чего Я хотел избежать.


        1. Fox_exe
          26.03.2018 09:19
          +1

          Lua тоже не часть Nginx, а сторонний модуль, который надо ставить отдельно, на скок я помню.

          А так — Обычная авторизация по htpasswd (nginx_auth) + Fail2ban для защиты от брута вполне достаточно.


  1. TicSo
    24.03.2018 15:54

    Какие на Ваш взгляд, у предложенного решения, недостатки,
    возможно, есть что-то в планах добавить / улучшить? Например, по

    «Хотя бы минимальная защита от перебора»

    Спасибо.


    1. seivgard Автор
      25.03.2018 10:09

      На мой взгляд, счётчиков вполне достаточно. Тем более у меня дополнительная защита по принципу "Неуловимого Джо"
      А в планах: У меня уже реализована общая страница с псевдооконным интерфейсом на jquery ui, чтобы работать с несколькими сервисами, возможно оформлю это в статью, но пока там маловато информации даже на заметку…


  1. devpreview
    24.03.2018 20:58

    Есть ещё ngx_http_auth_request_module — можно реализовать аутентификацию на любом ЯП.


    1. seivgard Автор
      25.03.2018 10:12

      Чтобы включить ngx_http_auth_request_module нужно пересобирать nginx с этим модулем, по умолчанию это модуль не включен даже в nginx-extras


      1. devpreview
        25.03.2018 14:48

        deb nginx.org/packages/mainline/debian codename nginx — здесь включен.


  1. YourChief
    25.03.2018 01:19
    +1

    Рассмотрите вариант с аутентификацией по сертификатам TLS. В самой конфигурации nginx нужно будет тогда всего-навсего добавить две строчки вида:

    ssl_client_certificate /path/to/nginx/ssl/ca.crt;
    ssl_verify_client on;

    Использование сертификатов даёт максимальную защиту от перебора. TLS-аутентификация не пересекается с Basic-аутентификацией защищаемых сервисов, если они таковую имеют, и поэтому можно считать, что требование с отдельной страницей входа соблюдено. Поскольку такое решение опирается на основополагающие механизмы, то можно сказать, что минималистичность требований к устанавливаемому ПО аж зашкаливает — ведь в этой схеме не нужен даже Lua.

    На хабре есть статья про настройку аутентификации клиентов через TLS, однако там используются ручные команды openssl. Гораздо проще воспользоваться скриптами из пакета easy-rsa, чтобы автоматизировать процесс выпуска упакованных сертификатов и генерации CRL, если нужно.

    Предложу Вашему вниманию несколько замечаний по lua-коду:
    1. Несмотря на то, что функции вынесены в общий словарь, счётчики у каждого рабочего процесса будут свои. В документации к модулю lua написано о возможных подходах к разделению данных между рабочими процессами.
    2. Крайне рекомендуется везде использовать локальные переменные. Глобальные переменные в случае nginx добавляют только проблем и всё равно не решают вопрос с разделением данных хотя бы даже между запросами одного рабочего процесса nginx. Для того, чтобы проверить ваш код имеется специальная утилита lua-releng, подготовленная разработчиком модуля lua для nginx.
    3. В nginx есть встроенные функции для вычисления md5 и sha1. Для вашей задачи с криптографической точки зрения корректно использовать только функцию hmac_sha1 вместо манипуляций с конкатенацией строки для хэширования. Подробнее об этом будет ниже.
    4. Функцию nvl можно заменить идиомой
      (val or default)
      , которая будет принимать значение default во всех случаях, когда val == nil либо val = false (и только тогда).
    5. Чтение из файла — блокирующая операция, которая приостановит выполнение ВСЕХ запросов рабочим процессом. Сетевые операции, осуществляемые через специальное API косокетов в Nginx, напротив, не блокируют выполнение остальных запросов. Поэтому часто можно увидеть использование Redis или других БД из модулей lua в nginx.
    6. «Токен» валиден в пределах тех же суток, в которые выдан. Это достаточно непрактично в окрестности полуночи. Вместо этого обычно открытым текстом записывают прямо в токен срок окончания действия и всю строку подписывают HMAC-ом. Пример реализации того же самого в nginx (но куки ставит отдельный сервер, это легко переделать).
    7. Ваш код уязвим к атаке удлинением сообщения. Предположим, пользователь в прошлом имел доступ к некоторому сервису и получил токен в виде хэшсуммы из секретного ключа, юзерагента и даты. Тогда даже после смены пароля или отзыва доступа он может обойти аутентификацию, рассчитав удлинённый хэш от своего старого значения юзерагента + старой даты + требуемого для удлинения паддинга + текущей даты. Подставив в юзерагент всё кроме даты он пройдёт проверку. Вот поэтому нужно применять HMAC, а не конкатенацию строк, чтобы не наступать на грабли при использовании криптографических примитивов.


    1. seivgard Автор
      25.03.2018 10:43

      Про аутентификацию по сертификату думал, но отказался ввиду неудобности лично для меня: Например в гостях захочу включить какой-нибудь фильм с чужого ПК, для этого у меня с собой должна быть флешка с клиентским сертификатом. Логин/пароль в данном плане удобнее — он всегда с собой, в голове.
      1. Дополнительно изучу этот вопрос. Но проверка показывала, что счётчики общие при попытках доступа с разных IP/ПК.
      2. Проверю, Я вроде бы везде старался использовать локальные переменные.
      3. А вот за это — Спасибо! Поменяю. Как-то Я их упустил…
      4. Поменяю. Всё таки полчаса на изучения lua перед реализацией, видимо, не достаточно.
      5. Всё-таки у меня не та нагрузка, чтобы это могло принести какие-либо неудобства. Я даже с трудом могу представить кейс, когда мне может потребоваться одновременная аутентификация из двух разных мест. Но решение однозначно не промышленное, для личного пользования…
      6, 7. Посмотрю варианты реализации через HMAC.

      Спасибо за конструктивную критику.
      Думаю, в ближайшее время решение претерпит некоторые изменения.