Доброго времени суток. В данной заметке хочу рассказать о простой аутентификации с помощь nginx и lua-скриптов.
Подняв у себя домашний сервер на ubuntu с plex и transmission и обзаведясь доменом, через который вывел это добро в большой мир, понял Я, что было бы неплохо обзавестись единой точкой аутентификации. Тем более nginx у меня уже был установлен (даже nginx-extras, что немаловажно, поскольку там есть lua).
Собравшись с мыслями, сформулировал требования:
- Отсутствие необходимости установки дополнительного ПО
- Отдельная страница аутентификации
- Сквозная аутентификация для всех сервисов за nginx
- Хотя бы минимальная защита от перебора
Вариант с nginx basic auth не устроил по причине отсутствия защиты от перебора, вариант с nginx auth PAM вызвал у меня недоверие по причине аутентификации по логину/паролю ОС. И оба варианта не дают возможности аутентификации через свою отдельную форму.
Алгоритм аутентификации довольно прост:
Ну что ж, приступим.
Для начала создадим 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:
• • •
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):
-- Адрес страницы аутентификации
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)
Добавим проверку данным скриптом в конфиги внутренних сервисов:
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;
• • •
}
Создадим страницу аутентификации:
<!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:
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)
-- Берём из глобального контейнера 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)
-- Берём из глобального контейнера 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)
kolu4iy
24.03.2018 13:42auth pam? Там же можно правами пользователей все разрулить… Пользователь без консоли может в nginx и ничего более, а остальные не авторизуются на nginx… Нет, ваш способ работает, но чем система проще, тем меньше точнее отказа.
kolu4iy
24.03.2018 13:44А на auth basic проще натянуть fail2ban или что-то подобное, например...
seivgard Автор
25.03.2018 10:03fail2ban — это всё таки установка доп. ПО, чего Я хотел избежать.
Fox_exe
26.03.2018 09:19+1Lua тоже не часть Nginx, а сторонний модуль, который надо ставить отдельно, на скок я помню.
А так — Обычная авторизация по htpasswd (nginx_auth) + Fail2ban для защиты от брута вполне достаточно.
TicSo
24.03.2018 15:54Какие на Ваш взгляд, у предложенного решения, недостатки,
возможно, есть что-то в планах добавить / улучшить? Например, по
«Хотя бы минимальная защита от перебора»
Спасибо.seivgard Автор
25.03.2018 10:09На мой взгляд, счётчиков вполне достаточно. Тем более у меня дополнительная защита по принципу "Неуловимого Джо"
А в планах: У меня уже реализована общая страница с псевдооконным интерфейсом на jquery ui, чтобы работать с несколькими сервисами, возможно оформлю это в статью, но пока там маловато информации даже на заметку…
devpreview
24.03.2018 20:58Есть ещё ngx_http_auth_request_module — можно реализовать аутентификацию на любом ЯП.
seivgard Автор
25.03.2018 10:12Чтобы включить ngx_http_auth_request_module нужно пересобирать nginx с этим модулем, по умолчанию это модуль не включен даже в nginx-extras
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-коду:
- Несмотря на то, что функции вынесены в общий словарь, счётчики у каждого рабочего процесса будут свои. В документации к модулю lua написано о возможных подходах к разделению данных между рабочими процессами.
- Крайне рекомендуется везде использовать локальные переменные. Глобальные переменные в случае nginx добавляют только проблем и всё равно не решают вопрос с разделением данных хотя бы даже между запросами одного рабочего процесса nginx. Для того, чтобы проверить ваш код имеется специальная утилита lua-releng, подготовленная разработчиком модуля lua для nginx.
- В nginx есть встроенные функции для вычисления md5 и sha1. Для вашей задачи с криптографической точки зрения корректно использовать только функцию hmac_sha1 вместо манипуляций с конкатенацией строки для хэширования. Подробнее об этом будет ниже.
- Функцию nvl можно заменить идиомой
, которая будет принимать значение default во всех случаях, когда val == nil либо val = false (и только тогда).(val or default)
- Чтение из файла — блокирующая операция, которая приостановит выполнение ВСЕХ запросов рабочим процессом. Сетевые операции, осуществляемые через специальное API косокетов в Nginx, напротив, не блокируют выполнение остальных запросов. Поэтому часто можно увидеть использование Redis или других БД из модулей lua в nginx.
- «Токен» валиден в пределах тех же суток, в которые выдан. Это достаточно непрактично в окрестности полуночи. Вместо этого обычно открытым текстом записывают прямо в токен срок окончания действия и всю строку подписывают HMAC-ом. Пример реализации того же самого в nginx (но куки ставит отдельный сервер, это легко переделать).
- Ваш код уязвим к атаке удлинением сообщения. Предположим, пользователь в прошлом имел доступ к некоторому сервису и получил токен в виде хэшсуммы из секретного ключа, юзерагента и даты. Тогда даже после смены пароля или отзыва доступа он может обойти аутентификацию, рассчитав удлинённый хэш от своего старого значения юзерагента + старой даты + требуемого для удлинения паддинга + текущей даты. Подставив в юзерагент всё кроме даты он пройдёт проверку. Вот поэтому нужно применять HMAC, а не конкатенацию строк, чтобы не наступать на грабли при использовании криптографических примитивов.
seivgard Автор
25.03.2018 10:43Про аутентификацию по сертификату думал, но отказался ввиду неудобности лично для меня: Например в гостях захочу включить какой-нибудь фильм с чужого ПК, для этого у меня с собой должна быть флешка с клиентским сертификатом. Логин/пароль в данном плане удобнее — он всегда с собой, в голове.
1. Дополнительно изучу этот вопрос. Но проверка показывала, что счётчики общие при попытках доступа с разных IP/ПК.
2. Проверю, Я вроде бы везде старался использовать локальные переменные.
3. А вот за это — Спасибо! Поменяю. Как-то Я их упустил…
4. Поменяю. Всё таки полчаса на изучения lua перед реализацией, видимо, не достаточно.
5. Всё-таки у меня не та нагрузка, чтобы это могло принести какие-либо неудобства. Я даже с трудом могу представить кейс, когда мне может потребоваться одновременная аутентификация из двух разных мест. Но решение однозначно не промышленное, для личного пользования…
6, 7. Посмотрю варианты реализации через HMAC.
Спасибо за конструктивную критику.
Думаю, в ближайшее время решение претерпит некоторые изменения.
0x12ee705
Мне понравилось Ваше решение)))