В данной статье будет показан процесс настройки OpenVPN сервера на базе pfSense (но подойдет и любой другой) с подключением пользователей с аутентификацией с использованием SSL/TLS и Telegram в качестве 2FA.
Почему я вообще занялся этим вопросом:
В интернете нашел только платные сервисы с подобным функционалом (уверен, что под капотом у них почти то же самое, что будет в этой статье).
Помимо того, что они платные, не хочется отдавать на сторону процесс подключения чисто с точки зрения ИБ.
Показать возможность использовать не только ldap.
Возможное расширение функционала.
Чисто спортивный интерес )
Исходные данные:
Закрытый сетевой контур, где точкой входа служит pfSense.
Внутри контура (в любом другом месте, откуда/куда есть защищенный канал c pfSense, и настроена сетевая связность) есть сервер с возможностью запускать docker + docker‑compose, где будет запущен freeradius, с сервера должен быть выход в интернет для того, чтобы работала связка с telegram.
Зарегистрированный Telegram бот через @BotFather — известен адрес и токен бота.
Минимальные/достаточные знания: как запускать docker и писать код на python
Настроить pfSense:
Установить пакет openvpn-client-export:

После установки, пакет должен появиться в списке установленных:

Настроить OpenVPN Сервер
1. Создать CA сертификат, введите свои актуальные данные:

2. Создать сертификат с этим CA, введите свои актуальные данные:

3. Создать Revocation List с этим CA:

4. Добавить сервер аутентификации, где указать ip адрес сервера, где будет в docker запущен freeradius сервер:

SharedSecret — пароль с которым pfSense будет ходить на radius сервер, понадобится при настройке freeradius
5. Создать OpenVPN сервер, указать все, что создали ранее, а также ваши параметры по необходимости:





Важно! в Custom options установите параметры:
reneg-sec 0;
hand-window 120;
6. Настроить Client Export:

Важно! в Additional configuration options установите параметры:
reneg-sec 0
hand-window 120
Почему важно прописать параметры:
reneg-sec 0
hand-window 120
Вот выдержка из документации:
https://openvpn.net/community-resources/reference-manual-for-openvpn-2-4/
--reneg-sec n Renegotiate data channel key after n seconds (default=3600).When using dual-factor authentication, note that this default value may cause the end user to be challenged to reauthorize once per hour.
Also, keep in mind that this option can be used on both the client and server, and whichever uses the lower value will be the one to trigger the renegotiation. A common mistake is to set --reneg-sec to a higher value on either the client or server, while the other side of the connection is still using the default value of 3600 seconds, meaning that the renegotiation will still occur once per 3600 seconds. The solution is to increase --reneg-sec on both the client and server, or set it to 0 on one side of the connection (to disable), and to your chosen value on the other side.
Если коротко: по умолчанию OpenVPN раз в час пытается согласовывать ключ между сервером и клиентом, что приводит к повторной попытке аутентификации. Если вам не критично раз в час подтверждать соединение, то можно не добавлять эти параметры, либо выставить в необходимое вам значение. Причем этот параметр важно прописать как со стороны сервера, так и со стороны клиента.
Установив значение в 0 — периодическое согласование будет отключено.
Далее более интересная часть :-)
Настроить FreeRadius и Telegram бота
Для отправки подтверждения конкретному пользователю в Telegram, необходимо знать его chatid и отслеживать связку с именем пользователя, который будет прилетать от OpenVPN сервера. Для этого необходимо, например, создать небольшой web интерфейс для удобства (либо использовать уже готовые решения) управления этими связками. Я приведу пример этого портала, предлагаю вам им воспользоваться, чтобы посмотреть какая структура БД будет создана — это будет важно для понимания:
Структура файлов web приложения:
app
├── main.py
├── static
│ ├── script.js
│ └── style.css
└── templates
├── add_user.html
├── base.html
├── edit_request.html
├── edit_user.html
├── pending_requests.html
└── users.html
Файлы:
main.py
import os
from flask import Flask, render_template, request, redirect, url_for, flash
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import Column, String, Boolean, DateTime, Integer
app = Flask(__name__)
# Конфигурация БД
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv(
'DATABASE_URL',
'postgresql://postgres:postgres@db/postgres'
)
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.secret_key = os.getenv('FLASK_SECRET_KEY', 'dev-secret-key')
# Инициализация SQLAlchemy
db = SQLAlchemy(app)
# Модели
class User(db.Model):
__tablename__ = 'users'
username = Column(String(255), primary_key=True)
chat_id = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
blocked_reason = Column(String(255), nullable=True)
blocked_at = Column(DateTime, nullable=True)
blocked_by = Column(String(255), nullable=True)
class PendingRequest(db.Model):
__tablename__ = 'pending_requests'
username = Column(String(255))
expiry = Column(DateTime, nullable=False)
status = Column(String(20), nullable=False, default='pending')
message_id = Column(Integer, primary_key=True)
# Маршруты
@app.route('/')
def index():
return redirect(url_for('users'))
@app.route('/users')
def users():
users_list = User.query.order_by(User.username).all()
return render_template('users.html', users=users_list)
@app.route('/user/edit/<username>', methods=['GET', 'POST'])
def edit_user(username):
user = User.query.get_or_404(username)
if request.method == 'POST':
user.chat_id = request.form['chat_id']
user.is_active = request.form.get('is_active') == 'on'
user.blocked_reason = request.form['blocked_reason'] or None
user.blocked_by = request.form['blocked_by'] or None
db.session.commit()
flash('User updated successfully!', 'success')
return redirect(url_for('users'))
return render_template('edit_user.html', user=user)
@app.route('/pending_requests')
def pending_requests():
status_filter = request.args.get('status', 'all')
query = PendingRequest.query
if status_filter != 'all':
query = query.filter(PendingRequest.status == status_filter)
requests_list = query.order_by(PendingRequest.expiry).all()
return render_template('pending_requests.html',
requests=requests_list,
status_filter=status_filter)
@app.route('/request/edit/<int:message_id>', methods=['GET', 'POST'])
def edit_request(message_id):
req = PendingRequest.query.get_or_404(message_id)
if request.method == 'POST':
req.status = request.form['status']
db.session.commit()
flash('Request updated successfully!', 'success')
return redirect(url_for('pending_requests'))
return render_template('edit_request.html', request=req)
@app.route('/user/add', methods=['GET', 'POST'])
def add_user():
if request.method == 'POST':
username = request.form['username']
chat_id = request.form['chat_id']
# Проверяем, не существует ли уже пользователь
if User.query.get(username):
flash('User with this username already exists!', 'error')
return redirect(url_for('add_user'))
if User.query.get(chat_id):
flash('User with this chat_id already exists!', 'error')
return redirect(url_for('add_user'))
new_user = User(
username=username,
chat_id=chat_id,
is_active=request.form.get('is_active') == 'on'
)
db.session.add(new_user)
db.session.commit()
flash('User added successfully!', 'success')
return redirect(url_for('users'))
return render_template('add_user.html')
@app.route('/user/delete/<username>', methods=['POST'])
def delete_user(username):
user = User.query.get_or_404(username)
db.session.delete(user)
db.session.commit()
flash('User deleted successfully!', 'success')
return redirect(url_for('users'))
if __name__ == '__main__':
with app.app_context():
db.create_all()
app.run(host='0.0.0.0', port=5000, debug=True)
script.js
document.addEventListener('DOMContentLoaded', function() {
const deleteForms = document.querySelectorAll('.delete-form');
deleteForms.forEach(form => {
form.addEventListener('submit', function(e) {
e.preventDefault();
if (confirm('Are you sure you want to delete this user?')) {
this.submit();
}
});
});
});
style.css
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
line-height: 1.6;
}
nav {
background: #333;
color: #fff;
padding: 1rem;
}
nav ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
}
nav ul li {
margin-right: 1rem;
}
nav ul li a {
color: #fff;
text-decoration: none;
}
.container {
padding: 2rem;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
table th, table td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
table th {
background-color: #f4f4f4;
}
form div {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
}
input[type="text"],
input[type="number"],
select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
background: #333;
color: #fff;
border: none;
padding: 0.5rem 1rem;
cursor: pointer;
border-radius: 4px;
}
button:hover {
background: #555;
}
.alert {
padding: 0.75rem 1.25rem;
margin-bottom: 1rem;
border: 1px solid transparent;
border-radius: 0.25rem;
}
.alert-success {
color: #155724;
background-color: #d4edda;
border-color: #c3e6cb;
}
.filters a {
display: inline-block;
margin-right: 1rem;
padding: 0.5rem;
text-decoration: none;
color: #333;
border: 1px solid #ddd;
border-radius: 4px;
}
.filters a.active {
background: #333;
color: #fff;
}
.add-button {
background: #4CAF50;
color: white;
padding: 8px 16px;
text-decoration: none;
border-radius: 4px;
margin-left: 20px;
font-size: 14px;
}
.add-button:hover {
background: #45a049;
}
.actions {
display: flex;
gap: 8px;
}
.edit-btn2 {
background: #2196F3;
color: white;
padding: 6px 12px;
text-decoration: none;
border-radius: 4px;
font-size: 14px;
}
.edit-btn {
background: #2196F3;
color: white;
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.edit-btn, .delete-btn {
height: 32px; /* Фиксированная высота */
box-sizing: border-box; /* Чтобы padding не влиял на высоту */
}
.edit-btn:hover {
background: #0b7dda;
}
.delete-form {
margin: 0;
}
.delete-btn {
background: #f44336;
color: white;
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.delete-btn:hover {
background: #da190b;
}
add_user.html
{% extends "base.html" %}
{% block content %}
<h1>Add New User</h1>
<form method="POST">
<div>
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
</div>
<div>
<label for="chat_id">Chat ID:</label>
<input type="text" id="chat_id" name="chat_id" required>
</div>
<div>
<label for="is_active">Is Active:</label>
<input type="checkbox" id="is_active" name="is_active" checked>
</div>
<button type="submit">Add User</button>
</form>
<a href="{{ url_for('users') }}">Back to Users</a>
{% endblock %}
base.html
<!DOCTYPE html>
<html>
<head>
<title>DB Admin Interface</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<nav>
<ul>
<li><a href="{{ url_for('users') }}">Users</a></li>
<li><a href="{{ url_for('pending_requests') }}">Pending Requests</a></li>
</ul>
</nav>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="{{ url_for('static', filename='script.js') }}"></script>
</body>
</html>
edit_request.html
{% extends "base.html" %}
{% block content %}
<h1>Edit Request: {{ request.message_id }}</h1>
<form method="POST">
<div>
<label for="status">Status:</label>
<select id="status" name="status">
<option value="pending" {% if request.status == 'pending' %}selected{% endif %}>Pending</option>
<option value="approved" {% if request.status == 'approved' %}selected{% endif %}>Approved</option>
<option value="rejected" {% if request.status == 'rejected' %}selected{% endif %}>Rejected</option>
</select>
</div>
<button type="submit">Save Changes</button>
</form>
<a href="{{ url_for('pending_requests') }}">Back to Requests</a>
{% endblock %}
edit_user.html
{% extends "base.html" %}
{% block content %}
<h1>Edit User: {{ user.username }}</h1>
<form method="POST">
<div>
<label for="chat_id">Chat ID:</label>
<input type="text" id="chat_id" name="chat_id" value="{{ user.chat_id }}" required>
</div>
<div>
<label for="is_active">Is Active:</label>
<input type="checkbox" id="is_active" name="is_active" {% if user.is_active %}checked{% endif %}>
</div>
<div>
<label for="blocked_reason">Blocked Reason:</label>
<input type="text" id="blocked_reason" name="blocked_reason" value="{{ user.blocked_reason or '' }}">
</div>
<div>
<label for="blocked_by">Blocked By:</label>
<input type="text" id="blocked_by" name="blocked_by" value="{{ user.blocked_by or '' }}">
</div>
<button type="submit">Save Changes</button>
</form>
<a href="{{ url_for('users') }}">Back to Users</a>
{% endblock %}
pending_requests.html
{% extends "base.html" %}
{% block content %}
<h1>Pending Requests</h1>
<div class="filters">
<a href="{{ url_for('pending_requests', status='all') }}" {% if status_filter == 'all' %}class="active"{% endif %}>All</a>
<a href="{{ url_for('pending_requests', status='pending') }}" {% if status_filter == 'pending' %}class="active"{% endif %}>Pending</a>
<a href="{{ url_for('pending_requests', status='approved') }}" {% if status_filter == 'approved' %}class="active"{% endif %}>Approved</a>
<a href="{{ url_for('pending_requests', status='rejected') }}" {% if status_filter == 'rejected' %}class="active"{% endif %}>Rejected</a>
</div>
<table>
<thead>
<tr>
<th>Message ID</th>
<th>Username</th>
<th>Expiry</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for req in requests %}
<tr>
<td>{{ req.message_id }}</td>
<td>{{ req.username }}</td>
<td>{{ req.expiry }}</td>
<td>{{ req.status }}</td>
<td><a href="{{ url_for('edit_request', message_id=req.message_id) }}">Edit</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
users.html
{% extends "base.html" %}
{% block content %}
<h1>Users <a href="{{ url_for('add_user') }}" class="add-button">+ Add New</a></h1>
<table>
<thead>
<tr>
<th>Username</th>
<th>Chat ID</th>
<th>Status</th>
<th>Blocked Reason</th>
<th>Blocked By</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.username }}</td>
<td>{{ user.chat_id }}</td>
<td>{% if user.is_active %}Active{% else %}Blocked{% endif %}</td>
<td>{{ user.blocked_reason or '-' }}</td>
<td>{{ user.blocked_by or '-' }}</td>
<td class="actions">
<a href="{{ url_for('edit_user', username=user.username) }}" class="edit-btn">Edit</a>
<form action="{{ url_for('delete_user', username=user.username) }}" method="POST" class="delete-form">
<button type="submit" class="delete-btn">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
Общая структура файлов выглядит так (поменяйте везде пароли на свои!):
.
├── .env
├── Dockerfile.app
├── Dockerfile.freeradius
├── app
│ ├── main.py
│ ├── static
│ │ ├── script.js
│ │ └── style.css
│ └── templates
│ ├── add_user.html
│ ├── base.html
│ ├── edit_request.html
│ ├── edit_user.html
│ ├── pending_requests.html
│ └── users.html
├── config
│ ├── authorize.py
│ ├── clients.conf
│ ├── default
│ ├── python3
│ └── users
└── docker-compose.yml
.env
POSTGRES_PASSWORD=w4PMajjUuui7cJSow7qxRAwY4
DATABASE_URL=postgresql://freeradius:w4PMajjUuui7cJSow7qxRAwY4@free2fa-postgres-2/freeradius
FLASK_SECRET_KEY=W4wmqPNKazNyFpMmisr3FL96x
TELEGRAM_BOT_TOKEN=<telegram bot token>
Dockerfile.app
# Use an official Python runtime as a parent image
FROM python:3.9-slim
# Set the working directory in the container
WORKDIR /app
# Install any needed packages specified in requirements.txt
RUN pip install --no-cache-dir Flask==2.0.3 Werkzeug==2.0.3 Flask-SQLAlchemy==2.5.1 SQLAlchemy==1.4.23 psycopg2-binary==2.9.1
# Copy the current directory contents into the container at /app
COPY /app /app
# Make port 5000 available to the world outside this container
EXPOSE 5000
# Define environment variable
ENV FLASK_APP=app
ENV FLASK_ENV=development
# Run app.py when the container launches
CMD ["python","main.py"]
Dockerfile.freeradius
FROM freeradius/freeradius-server:latest
# Установка зависимостей Free2FA
RUN apt-get update && \
apt-get install -y python3 python3-pip libpq-dev freeradius-python3 && \
pip3 install python-telegram-bot==13.7 sqlalchemy psycopg2-binary && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Копирование конфигурации Free2FA
COPY config/authorize.py /etc/freeradius/3.0/free2fa/authorize.py
COPY config/python3 /etc/freeradius/mods-available/python3
COPY config/default /etc/freeradius/sites-enabled/default
COPY config/users /etc/freeradius/users
RUN chown -R freerad:freerad /etc/freeradius/ && \
chmod 750 /etc/freeradius/mods-config/python && \
ln -s /etc/freeradius/mods-available/python3 /etc/freeradius/mods-enabled/
EXPOSE 1812/udp 1813/udp
CMD ["radiusd", "-f", "-l", "/var/log/freeradius/radius.log"]
authorize.py
import logging
from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import Updater, CallbackQueryHandler, CommandHandler, MessageHandler, Filters
from sqlalchemy import create_engine, Column, String, DateTime, text, Integer, Boolean
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime, timedelta
import os
import time
import sys
import radiusd
# ========== КОНФИГУРАЦИЯ ==========
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(lineno)s - %(message)s',
level=logging.INFO,
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger(__name__)
# Параметры из переменных окружения
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN')
OTP_VALID_SECONDS = int(os.getenv('OTP_VALID_SECONDS', 300))
DB_URL = os.getenv('DATABASE_URL', 'postgresql://freeradius:securepassword@postgres-free2fa:5432/freeradius')
# ========== БАЗА ДАННЫХ ==========
Base = declarative_base()
class User(Base):
__tablename__ = 'users'
username = Column(String(255), primary_key=True)
chat_id = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True, nullable=False) # True - доступ разрешен, False - заблокирован
blocked_reason = Column(String(255), nullable=True) # Причина блокировки
blocked_at = Column(DateTime, nullable=True) # Когда заблокирован
blocked_by = Column(String(255), nullable=True) # Кто заблокировал (username админа)
class PendingRequest(Base):
__tablename__ = 'pending_requests'
username = Column(String(255))
expiry = Column(DateTime, nullable=False)
status = Column(String(20), nullable=False, default='pending')
message_id = Column(Integer, primary_key=True)
engine = create_engine(DB_URL, pool_pre_ping=True, pool_recycle=3600)
Session = sessionmaker(bind=engine)
# ========== TELEGRAM БОТ ==========
bot = Bot(token=TELEGRAM_BOT_TOKEN)
def start_bot():
"""Запуск Telegram бота для обработки кнопок"""
updater = Updater(token=TELEGRAM_BOT_TOKEN, use_context=True)
dp = updater.dispatcher
# Обработчики команд
dp.add_handler(CommandHandler("start", start_command))
dp.add_handler(CommandHandler("chatid", chatid_command))
# Обработчик нажатий кнопок
dp.add_handler(CallbackQueryHandler(button_handler))
# Обработчик текстовых сообщений
dp.add_handler(MessageHandler(Filters.text & ~Filters.command, handle_message))
updater.start_polling()
logger.info("Telegram bot started")
return updater
def start_command(update: Update, context):
"""Обработчик команды /start"""
user = update.effective_user
update.message.reply_text(
f"Привет, {user.first_name}!\n"
"Это бот для двухфакторной аутентификации VPN.\n\n"
"Доступные команды:\n"
"/chatid - показать ваш Chat ID\n"
)
def chatid_command(update: Update, context):
"""Обработчик команды /chatid - показывает chat_id пользователя"""
chat_id = update.message.chat_id
update.message.reply_text(
f"Ваш Chat ID: `{chat_id}`\n\n"
"Этот идентификатор нужен для привязки вашего аккаунта VPN к этому чату.",
parse_mode='Markdown'
)
def handle_message(update: Update, context):
"""Обработчик текстовых сообщений"""
update.message.reply_text(
"Я не понимаю текстовые сообщения. Используйте команды:\n"
"/chatid - показать ваш Chat ID\n"
)
def button_handler(update, context):
"""Обработчик нажатий кнопок в Telegram"""
query = update.callback_query
query.answer()
action, username = query.data.split('_')
session = Session()
# Удаляем сообщение с кнопками
try:
bot.delete_message(
chat_id=query.message.chat_id,
message_id=query.message.message_id
)
except Exception as e:
logger.error(f"Failed to delete message: {e}")
try:
# Находим активный запрос
request = session.query(PendingRequest).filter(
PendingRequest.username == username, PendingRequest.message_id == query.message.message_id,
PendingRequest.expiry > datetime.now()
).first()
if not request:
query.edit_message_text("⚠️ Время действия запроса истекло")
return
# Обновляем статус
request.status = 'approved' if action == 'approve' else 'denied'
session.commit()
# query.edit_message_text(
# f"✅ Доступ для {username} разрешен" if action == 'approve'
# else f"❌ Доступ для {username} запрещен"
# )
except Exception as e:
session.rollback()
logger.error(f"Button handler error: {e}")
query.edit_message_text("⚠️ Ошибка обработки запроса")
finally:
session.close()
# ========== RADIUS ФУНКЦИИ ==========
def authorize(p):
"""Обработка запроса авторизации"""
try:
attrs = dict(p)
username = attrs.get('User-Name')
if not username:
logger.error("No username provided")
return radiusd.RLM_MODULE_REJECT
session = Session()
try:
# Проверяем существование пользователя
user = session.query(User).filter_by(username=username).first()
if not user or not user.is_active:
logger.error(f"User {username} not found or blocked")
return radiusd.RLM_MODULE_REJECT
# Генерируем OTP
expiry = datetime.now() + timedelta(seconds=OTP_VALID_SECONDS)
# Отправляем сообщение в Telegram
keyboard = [
[InlineKeyboardButton("✅ Подтвердить", callback_data=f"approve_{username}"),
InlineKeyboardButton("❌ Отклонить", callback_data=f"deny_{username}")]
]
message = bot.send_message(
chat_id=user.chat_id,
text=f"🔐 Запрос на авторизацию VPN для *{username}*\n\n",
parse_mode='Markdown',
reply_markup=InlineKeyboardMarkup(keyboard))
# Обновляем запрос в базе
session.query(PendingRequest).filter_by(username=username).delete()
session.add(PendingRequest(
username=username,
expiry=expiry,
message_id=message.message_id # <-- Сохраняем ID
))
session.commit()
return radiusd.RLM_MODULE_OK, {
'control:Auth-Type': 'PAP'
}
except Exception as e:
session.rollback()
logger.error(f"Authorization error for {username}: {e}")
return radiusd.RLM_MODULE_REJECT
finally:
session.close()
except Exception as e:
logger.error(f"Unexpected error in authorize: {e}")
return radiusd.RLM_MODULE_REJECT
def handle_authenticate(p):
"""Интерфейс для FreeRADIUS authenticate"""
try:
attrs = dict(p)
username = attrs.get('User-Name')
session = Session()
try:
# Проверяем блокировку
user = session.query(User).filter_by(username=username).first()
if not user or not user.is_active:
return radiusd.RLM_MODULE_REJECT
# Проверяем активный запрос
pending_request = session.query(PendingRequest).filter(
PendingRequest.username == username,
PendingRequest.expiry > datetime.now()
).first()
if not pending_request:
logger.error(f"No active request for {username}")
return radiusd.RLM_MODULE_REJECT
# Проверяем статус
if pending_request.status == 'approved':
logger.info(f"User {username} approved via Telegram")
return radiusd.RLM_MODULE_OK
# Ждем подтверждения от пользователя (макс 30 секунд)
logger.info(f"Waiting for Telegram confirmation for {username}...")
for _ in range(30): # 30 попыток с интервалом 1 секунда
session.refresh(pending_request) # Обновляем данные из БД
if pending_request.status == 'approved':
return radiusd.RLM_MODULE_OK
if pending_request.status == 'denied':
return radiusd.RLM_MODULE_REJECT
time.sleep(1) # Пауза 1 секунда
logger.error(f"Invalid AUTH for {username}")
return radiusd.RLM_MODULE_REJECT
except Exception as e:
session.rollback()
logger.error(f"Authentication error for {username}: {e}")
return radiusd.RLM_MODULE_REJECT
finally:
session.close()
except Exception as e:
logger.error(f"Error in handle_authenticate: {e}")
return radiusd.RLM_MODULE_REJECT
# ========== ЗАПУСК ПРОГРАММЫ ==========
if __name__ == '__main__':
logger.info("start...")
else:
# Режим работы с FreeRADIUS
bot_updater = start_bot()
logger.info("Free2FA module initialized and ready")
clients.conf
client pfsense {
ipaddr = 0.0.0.0/0
secret =<RADIUS_SECRET>
require_message_authenticator = no
nastype = other
shortname = pfsense
limit_proxy_state = true
}
где RADIUS_SECRET тот самый #radius_secret
default
В файле default изменены только эти секции:
authorize {
preprocess
python3
if (ok) {
update control {
Auth-Type := pap
}
}
}
authenticate {
#
# PAP authentication, when a back-end database listed
# in the 'authorize' section supplies a password. The
# password can be clear-text, or encrypted.
Auth-Type PAP {
python3
}
}
весь фаил default (очень длинный):
default
######################################################################
#
# As of 2.0.0, FreeRADIUS supports virtual hosts using the
# "server" section, and configuration directives.
#
# Virtual hosts should be put into the "sites-available"
# directory. Soft links should be created in the "sites-enabled"
# directory to these files. This is done in a normal installation.
#
# If you are using 802.1X (EAP) authentication, please see also
# the "inner-tunnel" virtual server. You will likely have to edit
# that, too, for authentication to work.
#
# $Id: 5046e59429b1510d5a2372593a60d16f2b951d7b $
#
######################################################################
#
# Read "man radiusd" before editing this file. See the section
# titled DEBUGGING. It outlines a method where you can quickly
# obtain the configuration you want, without running into
# trouble. See also "man unlang", which documents the format
# of this file.
#
# This configuration is designed to work in the widest possible
# set of circumstances, with the widest possible number of
# authentication methods. This means that in general, you should
# need to make very few changes to this file.
#
# The best way to configure the server for your local system
# is to CAREFULLY edit this file. Most attempts to make large
# edits to this file will BREAK THE SERVER. Any edits should
# be small, and tested by running the server with "radiusd -X".
# Once the edits have been verified to work, save a copy of these
# configuration files somewhere. (e.g. as a "tar" file). Then,
# make more edits, and test, as above.
#
# There are many "commented out" references to modules such
# as ldap, sql, etc. These references serve as place-holders.
# If you need the functionality of that module, then configure
# it in radiusd.conf, and un-comment the references to it in
# this file. In most cases, those small changes will result
# in the server being able to connect to the DB, and to
# authenticate users.
#
######################################################################
server default {
#
# If you want the server to listen on additional addresses, or on
# additional ports, you can use multiple "listen" sections.
#
# Each section make the server listen for only one type of packet,
# therefore authentication and accounting have to be configured in
# different sections.
#
# The server ignore all "listen" section if you are using '-i' and '-p'
# on the command line.
#
listen {
# Type of packets to listen for.
# Allowed values are:
# auth listen for authentication packets
# acct listen for accounting packets
# auth+acct listen for both authentication and accounting packets
# proxy IP to use for sending proxied packets
# detail Read from the detail file. For examples, see
# raddb/sites-available/copy-acct-to-home-server
# status listen for Status-Server packets. For examples,
# see raddb/sites-available/status
# coa listen for CoA-Request and Disconnect-Request
# packets. For examples, see the file
# raddb/sites-available/coa
#
type = auth
# Note: "type = proxy" lets you control the source IP used for
# proxying packets, with some limitations:
#
# * A proxy listener CANNOT be used in a virtual server section.
# * You should probably set "port = 0".
# * Any "clients" configuration will be ignored.
#
# See also proxy.conf, and the "src_ipaddr" configuration entry
# in the sample "home_server" section. When you specify the
# source IP address for packets sent to a home server, the
# proxy listeners are automatically created.
# ipaddr/ipv4addr/ipv6addr - IP address on which to listen.
# If multiple ones are listed, only the first one will
# be used, and the others will be ignored.
#
# The configuration options accept the following syntax:
#
# ipv4addr - IPv4 address (e.g.192.0.2.3)
# - wildcard (i.e. *)
# - hostname (radius.example.com)
# Only the A record for the host name is used.
# If there is no A record, an error is returned,
# and the server fails to start.
#
# ipv6addr - IPv6 address (e.g. 2001:db8::1)
# - wildcard (i.e. *)
# - hostname (radius.example.com)
# Only the AAAA record for the host name is used.
# If there is no AAAA record, an error is returned,
# and the server fails to start.
#
# ipaddr - IPv4 address as above
# - IPv6 address as above
# - wildcard (i.e. *), which means IPv4 wildcard.
# - hostname
# If there is only one A or AAAA record returned
# for the host name, it is used.
# If multiple A or AAAA records are returned
# for the host name, only the first one is used.
# If both A and AAAA records are returned
# for the host name, only the A record is used.
#
# ipv4addr = *
# ipv6addr = *
ipaddr = *
# Port on which to listen.
# Allowed values are:
# integer port number (1812)
# 0 means "use /etc/services for the proper port"
port = 0
# Some systems support binding to an interface, in addition
# to the IP address. This feature isn't strictly necessary,
# but for sites with many IP addresses on one interface,
# it's useful to say "listen on all addresses for eth0".
#
# If your system does not support this feature, you will
# get an error if you try to use it.
#
# interface = eth0
# Per-socket lists of clients. This is a very useful feature.
#
# The name here is a reference to a section elsewhere in
# radiusd.conf, or clients.conf. Having the name as
# a reference allows multiple sockets to use the same
# set of clients.
#
# If this configuration is used, then the global list of clients
# is IGNORED for this "listen" section. Take care configuring
# this feature, to ensure you don't accidentally disable a
# client you need.
#
# See clients.conf for the configuration of "per_socket_clients".
#
# clients = per_socket_clients
#
# Set the default UDP receive buffer size. In most cases,
# the default values set by the kernel are fine. However, in
# some cases the NASes will send large packets, and many of
# them at a time. It is then possible to overflow the
# buffer, causing the kernel to drop packets before they
# reach FreeRADIUS. Increasing the size of the buffer will
# avoid these packet drops.
#
# recv_buff = 65536
#
# Connection limiting for sockets with "proto = tcp".
#
# This section is ignored for other kinds of sockets.
#
limit {
#
# Limit the number of simultaneous TCP connections to the socket
#
# The default is 16.
# Setting this to 0 means "no limit"
max_connections = 16
# The per-socket "max_requests" option does not exist.
#
# The lifetime, in seconds, of a TCP connection. After
# this lifetime, the connection will be closed.
#
# Setting this to 0 means "forever".
lifetime = 0
#
# The idle timeout, in seconds, of a TCP connection.
# If no packets have been received over the connection for
# this time, the connection will be closed.
#
# In general, the client should close connections when
# they are idle. This setting is here just to make
# sure that bad clients do not leave connections open
# for days.
#
# If an idle timeout is set for only a "client" or a
# "listen" section, that timeout is used.
#
# If an idle timeout is set for both a "client" and a
# "listen" section, then the smaller timeout is used.
#
# Setting this to 0 means "no timeout".
#
# We STRONGLY RECOMMEND that you set an idle timeout.
#
# Systems with many incoming connections (500+) should
# set this value to a lower number. There are only a
# limited number of usable file descriptors (usually
# 1024) due to Posix API issues. If many sockets are
# idle, it can prevent the server from opening new
# connections.
#
idle_timeout = 900
}
}
#
# This second "listen" section is for listening on the accounting
# port, too.
#
listen {
ipaddr = *
# ipv6addr = ::
port = 0
type = acct
# interface = eth0
# clients = per_socket_clients
limit {
# The number of packets received can be rate limited via the
# "max_pps" configuration item. When it is set, the server
# tracks the total number of packets received in the previous
# second. If the count is greater than "max_pps", then the
# new packet is silently discarded. This helps the server
# deal with overload situations.
#
# The packets/s counter is tracked in a sliding window. This
# means that the pps calculation is done for the second
# before the current packet was received. NOT for the current
# wall-clock second, and NOT for the previous wall-clock second.
#
# Useful values are 0 (no limit), or 100 to 10000.
# Values lower than 100 will likely cause the server to ignore
# normal traffic. Few systems are capable of handling more than
# 10K packets/s.
#
# It is most useful for accounting systems. Set it to 50%
# more than the normal accounting load, and you can be sure that
# the server will never get overloaded
#
# max_pps = 0
# Only for "proto = tcp". These are ignored for "udp" sockets.
#
# idle_timeout = 0
# lifetime = 0
# max_connections = 0
}
}
# IPv6 versions of the above - read their full config to understand options
listen {
type = auth
ipv6addr = :: # any. ::1 == localhost
port = 0
# interface = eth0
# clients = per_socket_clients
limit {
max_connections = 16
lifetime = 0
idle_timeout = 30
}
}
listen {
ipv6addr = ::
port = 0
type = acct
# interface = eth0
# clients = per_socket_clients
limit {
# max_pps = 0
# idle_timeout = 0
# lifetime = 0
# max_connections = 0
}
}
# Authorization. First preprocess (hints and huntgroups files),
# then realms, and finally look in the "users" file.
#
# Any changes made here should also be made to the "inner-tunnel"
# virtual server.
#
# The order of the realm modules will determine the order that
# we try to find a matching realm.
#
# Make *sure* that 'preprocess' comes before any realm if you
# need to setup hints for the remote radius server
authorize {
preprocess
python3
if (ok) {
update control {
Auth-Type := pap
}
}
}
# Authentication.
#
#
# This section lists which modules are available for authentication.
# Note that it does NOT mean 'try each module in order'. It means
# that a module from the 'authorize' section adds a configuration
# attribute 'Auth-Type := FOO'. That authentication type is then
# used to pick the appropriate module from the list below.
#
# In general, you SHOULD NOT set the Auth-Type attribute. The server
# will figure it out on its own, and will do the right thing. The
# most common side effect of erroneously setting the Auth-Type
# attribute is that one authentication method will work, but the
# others will not.
#
# The common reasons to set the Auth-Type attribute by hand
# is to either forcibly reject the user (Auth-Type := Reject),
# or to or forcibly accept the user (Auth-Type := Accept).
#
# Note that Auth-Type := Accept will NOT work with EAP.
#
# Please do not put "unlang" configurations into the "authenticate"
# section. Put them in the "post-auth" section instead. That's what
# the post-auth section is for.
#
authenticate {
#
# PAP authentication, when a back-end database listed
# in the 'authorize' section supplies a password. The
# password can be clear-text, or encrypted.
Auth-Type PAP {
python3
}
}
#
# Pre-accounting. Decide which accounting type to use.
#
preacct {
preprocess
#
# Merge Acct-[Input|Output]-Gigawords and Acct-[Input-Output]-Octets
# into a single 64bit counter Acct-[Input|Output]-Octets64.
#
# acct_counters64
#
# Session start times are *implied* in RADIUS.
# The NAS never sends a "start time". Instead, it sends
# a start packet, *possibly* with an Acct-Delay-Time.
# The server is supposed to conclude that the start time
# was "Acct-Delay-Time" seconds in the past.
#
# The code below creates an explicit start time, which can
# then be used in other modules. It will be *mostly* correct.
# Any errors are due to the 1-second resolution of RADIUS,
# and the possibility that the time on the NAS may be off.
#
# The start time is: NOW - delay - session_length
#
# update request {
# &FreeRADIUS-Acct-Session-Start-Time = "%{expr: %l - %{%{Acct-Session-Time}:-0} - %{%{Acct-Delay-Time}:-0}}"
# }
#
# Ensure that we have a semi-unique identifier for every
# request, and many NAS boxes are broken.
acct_unique
#
# Look for IPASS-style 'realm/', and if not found, look for
# '@realm', and decide whether or not to proxy, based on
# that.
#
# Accounting requests are generally proxied to the same
# home server as authentication requests.
# IPASS
suffix
# ntdomain
#
# Read the 'acct_users' file
files
}
#
# Accounting. Log the accounting data.
#
accounting {
# Update accounting packet by adding the CUI attribute
# recorded from the corresponding Access-Accept
# use it only if your NAS boxes do not support CUI themselves
# cui
#
# Create a 'detail'ed log of the packets.
# Note that accounting requests which are proxied
# are also logged in the detail file.
detail
# daily
# Update the wtmp file
#
# If you don't use "radlast" (becoming obsolete and no longer
# available on all systems), you can delete this line.
# unix
#
# For Simultaneous-Use tracking.
#
# Due to packet losses in the network, the data here
# may be incorrect. There is little we can do about it.
# radutmp
# sradutmp
#
# Return an address to the IP Pool when we see a stop record.
#
# Ensure that &control:Pool-Name is set to determine which
# pool of IPs are used.
# sqlippool
#
# Log traffic to an SQL database.
#
# See "Accounting queries" in mods-available/sql
-sql
#
# If you receive stop packets with zero session length,
# they will NOT be logged in the database. The SQL module
# will print a message (only in debugging mode), and will
# return "noop".
#
# You can ignore these packets by uncommenting the following
# three lines. Otherwise, the server will not respond to the
# accounting request, and the NAS will retransmit.
#
# if (noop) {
# ok
# }
# Cisco VoIP specific bulk accounting
# pgsql-voip
# For Exec-Program and Exec-Program-Wait
exec
# Filter attributes from the accounting response.
attr_filter.accounting_response
#
# See "Autz-Type Status-Server" for how this works.
#
# Acct-Type Status-Server {
#
# }
}
# Session database, used for checking Simultaneous-Use. Either the radutmp
# or rlm_sql module can handle this.
# The rlm_sql module is *much* faster
session {
#radutmp
# Интервал проверки состояния сессии (в секундах)
# По умолчанию: 60 минут (3600 секунд)
#interim-update = 86400
# Таймаут сессии (в секундах)
# По умолчанию: 60 минут (3600 секунд)
#session-timeout = 86400
#
# See "Simultaneous Use Checking Queries" in mods-available/sql
# sql
}
# Post-Authentication
# Once we KNOW that the user has been authenticated, there are
# additional steps we can take.
post-auth {
#
# If you need to have a State attribute, you can
# add it here. e.g. for later CoA-Request with
# State, and Service-Type = Authorize-Only.
#
# if (!&reply:State) {
# update reply {
# State := "0x%{randstr:16h}"
# }
# }
#
# Reject packets where User-Name != TLS-Client-Cert-Common-Name
# There is no reason for users to lie about their names.
#
# In general, User-Name == EAP Identity == TLS-Client-Cert-Common-Name
#
# verify_tls_client_common_name
#
# If there is no Stripped-User-Name in the request, AND we have a client cert,
# then create a Stripped-User-Name from the TLS client certificate information.
#
# Note that this policy MUST be edited for your local system!
# We do not know which fields exist in which certificate, as
# there is no standard here. There is no way for us to have
# a default configuration which "just works" everywhere. We
# can only make recommendations.
#
# The Stripped-User-Name is updated so that it is logged in
# the various "username" fields. This logging means that you
# can associate a particular session with a particular client
# certificate.
#
# if (&EAP-Message && !&Stripped-User-Name && &TLS-Client-Cert-Serial) {
# update request {
# &Stripped-User-Name := "%{%{TLS-Client-Cert-Subject-Alt-Name-Email}:-%{%{TLS-Client-Cert-Common-Name}:-%{TLS-Client-Cert-Serial}}}"
# }
#
#
# Create a Class attribute which is a hash of a bunch
# of information which we hope exists. This
# attribute should be echoed back in
# Accounting-Request packets, which will let the
# administrator correlate authentication and
# accounting.
#
# update reply {
# Class += "%{md5:%{Calling-Station-Id}%{Called-Station-Id}%{TLS-Client-Cert-Subject-Alt-Name-Email}%{TLS-Client-Cert-Common-Name}%{TLS-Client-Cert-Serial}%{NAS-IPv6-Address}%{NAS-IP-Address}%{NAS-Identifier}%{NAS-Port}"
# }
#
# }
#
# For EAP-TTLS and PEAP, add the cached attributes to the reply.
# The "session-state" attributes are automatically cached when
# an Access-Challenge is sent, and automatically retrieved
# when an Access-Request is received.
#
# The session-state attributes are automatically deleted after
# an Access-Reject or Access-Accept is sent.
#
# If both session-state and reply contain a User-Name attribute, remove
# the one in the reply if it is just a copy of the one in the request, so
# we don't end up with two User-Name attributes.
if (session-state:User-Name && reply:User-Name && request:User-Name && (reply:User-Name == request:User-Name)) {
update reply {
&User-Name !* ANY
}
}
update {
&reply: += &session-state:
}
#
# Refresh leases when we see a start or alive. Return an address to
# the IP Pool when we see a stop record.
#
# Ensure that &control:Pool-Name is set to determine which
# pool of IPs are used.
# sqlippool
# Create the CUI value and add the attribute to Access-Accept.
# Uncomment the line below if *returning* the CUI.
# cui
# Create empty accounting session to make simultaneous check
# more robust. See the accounting queries configuration in
# raddb/mods-config/sql/main/*/queries.conf for details.
#
# The "sql_session_start" policy is defined in
# raddb/policy.d/accounting. See that file for more details.
# sql_session_start
#
# If you want to have a log of authentication replies,
# un-comment the following line, and enable the
# 'detail reply_log' module.
# reply_log
#
# After authenticating the user, do another SQL query.
#
# See "Authentication Logging Queries" in mods-available/sql
-sql
#
# Un-comment the following if you want to modify the user's object
# in LDAP after a successful login.
#
# ldap
# For Exec-Program and Exec-Program-Wait
exec
#
# In order to calcualate the various keys for old style WiMAX
# (non LTE) you will need to define the WiMAX NAI, usually via
#
# update request {
# &WiMAX-MN-NAI = "%{User-Name}"
# }
#
# If you want various keys to be calculated, you will need to
# update the reply with "template" values. The module will see
# this, and replace the template values with the correct ones
# taken from the cryptographic calculations. e.g.
#
# update reply {
# &WiMAX-FA-RK-Key = 0x00
# &WiMAX-MSK = "%{reply:EAP-MSK}"
# }
#
# You may want to delete the MS-MPPE-*-Keys from the reply,
# as some WiMAX clients behave badly when those attributes
# are included. See "raddb/modules/wimax", configuration
# entry "delete_mppe_keys" for more information.
#
# For LTE style WiMAX you need to populate the following with the
# relevant values:
# control:WiMAX-SIM-Ki
# control:WiMAX-SIM-OPc
# control:WiMAX-SIM-AMF
# control:WiMAX-SIM-SQN
#
# wimax
# If there is a client certificate (EAP-TLS, sometimes PEAP
# and TTLS), then some attributes are filled out after the
# certificate verification has been performed. These fields
# MAY be available during the authentication, or they may be
# available only in the "post-auth" section.
#
# The first set of attributes contains information about the
# issuing certificate which is being used. The second
# contains information about the client certificate (if
# available).
#
# update reply {
# Reply-Message += "%{TLS-Cert-Serial}"
# Reply-Message += "%{TLS-Cert-Expiration}"
# Reply-Message += "%{TLS-Cert-Subject}"
# Reply-Message += "%{TLS-Cert-Issuer}"
# Reply-Message += "%{TLS-Cert-Common-Name}"
# Reply-Message += "%{TLS-Cert-Subject-Alt-Name-Email}"
#
# Reply-Message += "%{TLS-Client-Cert-Serial}"
# Reply-Message += "%{TLS-Client-Cert-Expiration}"
# Reply-Message += "%{TLS-Client-Cert-Subject}"
# Reply-Message += "%{TLS-Client-Cert-Issuer}"
# Reply-Message += "%{TLS-Client-Cert-Common-Name}"
# Reply-Message += "%{TLS-Client-Cert-Subject-Alt-Name-Email}"
# }
# Insert class attribute (with unique value) into response,
# aids matching auth and acct records, and protects against duplicate
# Acct-Session-Id. Note: Only works if the NAS has implemented
# RFC 2865 behaviour for the class attribute, AND if the NAS
# supports long Class attributes. Many older or cheap NASes
# only support 16-octet Class attributes.
# insert_acct_class
# MacSEC requires the use of EAP-Key-Name. However, we don't
# want to send it for all EAP sessions. Therefore, the EAP
# modules put required data into the EAP-Session-Id attribute.
# This attribute is never put into a request or reply packet.
#
# Uncomment the next few lines to copy the required data into
# the EAP-Key-Name attribute
# if (&reply:EAP-Session-Id) {
# update reply {
# EAP-Key-Name := &reply:EAP-Session-Id
# }
# }
# Remove reply message if the response contains an EAP-Message
remove_reply_message_if_eap
#
# Access-Reject packets are sent through the REJECT sub-section of the
# post-auth section.
#
# Add the ldap module name (or instance) if you have set
# 'edir = yes' in the ldap module configuration
#
# The "session-state" attributes are not available here.
#
Post-Auth-Type REJECT {
# log failed authentications in SQL, too.
-sql
attr_filter.access_reject
# Insert EAP-Failure message if the request was
# rejected by policy instead of because of an
# authentication failure
eap
# Remove reply message if the response contains an EAP-Message
remove_reply_message_if_eap
}
#
# Filter access challenges.
#
Post-Auth-Type Challenge {
# remove_reply_message_if_eap
# attr_filter.access_challenge.post-auth
}
#
# The Client-Lost section will be run for a request when
# FreeRADIUS has given up waiting for an end-users client to
# respond. This is most useful for logging EAP sessions where
# the client stopped responding (likely because the
# certificate was not acceptable.) i.e. this is not for
# RADIUS clients, but for end-user systems.
#
# This will only be triggered by new packets arriving,
# and will be run at some point in the future *after* the
# original request has been discarded.
#
# Therefore the *ONLY* attributes that are available here
# are those in the session-state list. If you want data
# to log, make sure it is copied to &session-state:
# before the client stops responding. NONE of the other
# original attributes (request, reply, etc) will be
# available.
#
# This section will only be run if `postauth_client_lost`
# is enabled in the main configuration in `radiusd.conf`.
#
# Note that there are MANY reasons why an end users system
# might not respond:
#
# * it could not get the packet due to firewall issues
# * it could not get the packet due to a lossy network
# * the users system might not like the servers cert
# * the users system might not like something else...
#
# In some cases, the client is helpful enough to send us a
# TLS Alert message, saying what it doesn't like about the
# certificate. In other cases, no such message is available.
#
# All that we can know on the FreeRADIUS side is that we sent
# an Access-Challenge, and the client never sent anything
# else. The reasons WHY this happens are buried inside of
# the logs on the client system. No amount of looking at the
# FreeRADIUS logs, or poking the FreeRADIUS configuration
# will tell you why the client gave up. The answers are in
# the logs on the client side. And no, the FreeRADIUS team
# didn't write the client, so we don't know where those logs
# are, or how to get at them.
#
# Information about the TLS state changes is in the
# &session-state:TLS-Session-Information attribute.
#
Post-Auth-Type Client-Lost {
#
# Debug ALL of the TLS state changes done during the
# EAP negotiation.
#
# %{debug_attr:&session-state:TLS-Session-Information[*]}
#
# Debug the LAST TLS state change done during the EAP
# negotiation. For errors, this is usually a TLS
# alert from the client saying something like
# "unknown CA".
#
# %{debug_attr:&session-state:TLS-Session-Information[n]}
#
# Debug the last module failure message. This may be
# useful, or it may refer to a server-side failure
# which did not cause the client to stop talking to the server.
#
# %{debug_attr:&session-state:Module-Failure-Message}
}
#
# If the client sends EAP-Key-Name in the request,
# then echo the real value back in the reply.
#
if (EAP-Key-Name && &reply:EAP-Session-Id) {
update reply {
&EAP-Key-Name := &reply:EAP-Session-Id
}
}
}
#
# When the server decides to proxy a request to a home server,
# the proxied request is first passed through the pre-proxy
# stage. This stage can re-write the request, or decide to
# cancel the proxy.
#
# Before this section is run, the request list is copied to the
# proxy list. The proxied packet can be edited by examining
# or changing attributes in the proxy list.
#
# Only a few modules currently have this method.
#
pre-proxy {
# Some supplicants will aggressively retry after an Access-Reject,
# contrary to standards. You can avoid sending excessive load to home
# servers that based on recent history is likely to only result in
# further authentication failures by calling the proxy_rate_limit
# module here and in the post-proxy section.
#
# If a request is send too soon after a home server returned an
# Access-Reject, then instead of proxying a request a Access-Reject
# will be returned.
#
# The principle is to expend a small amount of resources at the edge
# (an in-memory cache of recent rejects for calling stations) to
# defend the limited processing and network resources at the core.
#
# The strategy can be tuned in the module configuration.
#
# proxy_rate_limit
# Before proxing the request add an Operator-Name attribute identifying
# if the operator-name is found for this client.
# No need to uncomment this if you have already enabled this in
# the authorize section.
# operator-name
# The client requests the CUI by sending a CUI attribute
# containing one zero byte.
# Uncomment the line below if *requesting* the CUI.
# cui
# Uncomment the following line if you want to change attributes
# as defined in the preproxy_users file.
# files
# Uncomment the following line if you want to filter requests
# sent to remote servers based on the rules defined in the
# 'attrs.pre-proxy' file.
# attr_filter.pre-proxy
# If you want to have a log of packets proxied to a home
# server, un-comment the following line, and the
# 'detail pre_proxy_log' section, above.
# pre_proxy_log
}
#
# When the server receives a reply to a request it proxied
# to a home server, the request may be massaged here, in the
# post-proxy stage.
#
# Before this section is run, all attributes in the reply list
# are deleted. This section can then examine or edit the
# proxy_reply list. Once this section is finished, the attributes
# in the proxy_reply list are copied to the reply list.
#
post-proxy {
# If you want to have a log of replies from a home server,
# un-comment the following line, and the 'detail post_proxy_log'
# section, above.
# post_proxy_log
# Uncomment the following line if you want to filter replies from
# remote proxies based on the rules defined in the 'attrs' file.
# attr_filter.post-proxy
#
# The EAP module will perform some validation of proxied EAP
# packets. Malformed EAP packets will be rejected, and will
# not be proxied.
#
# This configuration is most useful to prevent bad
# supplicants or APs from attacking the proxies and home
# servers.
#
# eap
# If proxied requests are to be rate limited, then the
# proxy_rate_limit module must be called here to maintain a
# record of proxy responses.
#
# proxy_rate_limit
#
# If the server tries to proxy a request and fails, then the
# request is processed through the modules in this section.
#
# The main use of this section is to permit robust proxying
# of accounting packets. The server can be configured to
# proxy accounting packets as part of normal processing.
# Then, if the home server goes down, accounting packets can
# be logged to a local "detail" file, for processing with
# radrelay. When the home server comes back up, radrelay
# will read the detail file, and send the packets to the
# home server.
#
# See the "mods-available/detail.example.com" file for more
# details on writing a detail file specifically for one
# destination.
#
# See the "sites-available/robust-proxy-accounting" virtual
# server for more details on reading this "detail" file.
#
# With this configuration, the server always responds to
# Accounting-Requests from the NAS, but only writes
# accounting packets to disk if the home server is down.
#
# Post-Proxy-Type Fail-Accounting {
# detail.example.com
#
# Ensure a response is sent to the NAS now that the
# packet has been written to a detail file.
#
# acct_response
# }
}
}
python3
python3 {
python_path = "${modconfdir}/${.:name}:/etc/freeradius/3.0/free2fa:/usr/local/lib/python3.10/dist-packages"
mod_authorize = authorize
func_authorize = authorize
mod_authenticate = authorize
func_authenticate = handle_authenticate
}
users
DEFAULT Framed-Protocol == PPP
Framed-Protocol = PPP,
Framed-Compression = Van-Jacobson-TCP-IP
DEFAULT Hint == "CSLIP"
Framed-Protocol = SLIP,
Framed-Compression = Van-Jacobson-TCP-IP
DEFAULT Hint == "SLIP"
Framed-Protocol = SLIP
DEFAULT Auth-Type := PAP
Добавлена только последняя строчка, вырезаны все коментарии
docker-compose.yml
version: '3.8'
services:
free2fa-postgres-2:
image: postgres:16-alpine
container_name: free2fa-postgres-2
environment:
POSTGRES_USER: freeradius
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: freeradius
volumes:
- ./pgdata:/var/lib/postgresql/data
# ports:
# - "5432:5432"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U freeradius -d freeradius"]
interval: 5s
timeout: 5s
retries: 5
networks:
- free2fa-net
free2fa-web-2:
container_name: free2fa-web-2
build:
context: .
dockerfile: Dockerfile.app
ports:
- "5000:5000"
restart: unless-stopped
environment:
- FLASK_SECRET_KEY=${FLASK_SECRET_KEY}
- DATABASE_URL=${DATABASE_URL}
networks:
- free2fa-net
# FreeRADIUS с интеграцией Free2FA
free2fa-radius-2:
build:
context: .
dockerfile: Dockerfile.freeradius
container_name: free2fa-radius-2
environment:
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
DATABASE_URL: ${DATABASE_URL}
OTP_VALID_SECONDS: "300"
DISABLE_MSCHAP: "true"
ports:
- "1812-1813:1812-1813/udp"
restart: always
volumes:
- ./radius-logs:/var/log/freeradius
- ./config/clients.conf:/etc/freeradius/clients.conf
restart: unless-stopped
networks:
- free2fa-net
networks:
free2fa-net:
driver: bridge
ipam:
driver: default
config:
- subnet: 172.20.3.0/24
Запустить и пробовать
Собрать образы:
docker-compose build
Запустить:
docker-compose up -d
В приложении Telegram пользователь должен открыть чат с ботом, ввести команду /chatid
Создать привязку chatid и логина пользователя в web приложении:


Где 172.16.2.17 — сервер на котором запущен radius server и web приложение
Создать пользователя в pfSense с таким же Username, и выдать ему сертификат для openvpn:

Скачать конфигурацию для пользователя через OpenVPN → Client Export

Я рекомендую для всех платформ использовать кнопку Viscosity Inline Config
Попробовать подключиться (пароль проверяться в данном случае не будет), в чат с telegram ботом придет запрос на подтверждение подключения.
Очевидные минусы:
Конечно же это еще одна БД, за которой надо следить и своевременно добавлять/блокировать пользователей
API Telegram иногда может быть недоступен
Что дальше?
Дописать функционал web интерфейса для управления связками Username — chatid: для поиска, сортировки, пагинацию страниц и т. д.
Конечно же закрыть web интерфейс: паролем, другой программой... да как угодно.
Дописать бота под ситуации, когда API Telegram не отвечает.
Написать еще одного бота, например, для корпоративного мессенджера, например, mattermost, скрестить логику/последовательность подтверждения подключения через разные каналы связи.
Надеюсь был полезен :-)
Спасибо за внимание!