From 0584ebdc742d8dd9fc4ce50a879f504ec893544d Mon Sep 17 00:00:00 2001 From: test2 Date: Sun, 7 Jun 2026 02:57:49 +0300 Subject: [PATCH] Release v2.2: admin auth settings, Passkey RP ID, Cloudflare and Google captcha Co-authored-by: Cursor --- .env.example | 3 +- README.md | 27 +++++ app/__init__.py | 26 +++++ app/auth.py | 35 +++++- app/bootstrap.py | 32 +++++ app/captcha_service.py | 123 ++++++++++++++++++++ app/models.py | 19 +++ app/passkey.py | 9 ++ app/passkey_service.py | 15 +++ app/settings_service.py | 37 ++++++ app/templates/admin/settings.html | 54 ++++++++- app/templates/auth/forgot_password.html | 5 + app/templates/auth/login.html | 15 ++- app/templates/auth/register.html | 5 + app/templates/base.html | 2 + app/templates/cabinet/profile.html | 7 ++ app/templates/partials/captcha.html | 12 ++ app/templates/partials/captcha_scripts.html | 36 ++++++ 18 files changed, 458 insertions(+), 4 deletions(-) create mode 100644 app/captcha_service.py create mode 100644 app/templates/partials/captcha.html create mode 100644 app/templates/partials/captcha_scripts.html diff --git a/.env.example b/.env.example index fe2124c..6175547 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,8 @@ DEFAULT_GROUP_MAX_PHOTOS=500 ALLOW_GIT_DEPLOY=false GIT_REMOTE_URL=https://git.evilfox.cc/test2/fotohost.git -# WebAuthn / Passkey (use your domain in production) +# WebAuthn / Passkey — можно задать в админке (Настройки → Passkey) +# или через переменные окружения (админка имеет приоритет) WEBAUTHN_RP_ID=localhost WEBAUTHN_RP_NAME=PhotoHost WEBAUTHN_ORIGIN=http://localhost:8080 diff --git a/README.md b/README.md index 4a411fb..f325527 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,33 @@ docker compose up -d --build --- +## Релиз v2.2 + +**Настройки авторизации в админке** + +- Включение/отключение регистрации, входа по паролю и Passkey +- Passkey RP ID, RP Name и Origin — без правки `.env` +- Captcha: Cloudflare Turnstile, Google reCAPTCHA v2 и v3 +- Выбор страниц с captcha: вход, регистрация, сброс пароля + +**Captcha** + +- Один активный провайдер на сайт (настраивается в админке) +- reCAPTCHA v3 — проверка score на сервере (порог 0–1) + +**Обновление до v2.2 на сервере:** + +```bash +cd ~/fotohost +git fetch --tags +git checkout v2.2 +docker compose up -d --build +``` + +После деплоя откройте **Админка → Настройки** и задайте Passkey (RP ID / Origin) и captcha. + +--- + ## Релиз v2.1 **GDPR и cookies** diff --git a/app/__init__.py b/app/__init__.py index 3874690..2528942 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -90,6 +90,32 @@ def create_app(setup_database=True): except Exception: return {"site_banners": {}} + @app.context_processor + def inject_auth_settings(): + from app.settings_service import get_auth_public_settings + + try: + return {"auth_settings": get_auth_public_settings()} + except Exception: + return { + "auth_settings": { + "registration_enabled": True, + "password_login_enabled": True, + "passkey_enabled": True, + } + } + + @app.context_processor + def inject_captcha(): + from flask import request + + from app.captcha_service import get_captcha_config_for_request + + try: + return {"captcha_config": get_captcha_config_for_request(request.endpoint)} + except Exception: + return {"captcha_config": None} + if setup_database: with app.app_context(): from app.bootstrap import ( diff --git a/app/auth.py b/app/auth.py index f9b9109..272b5e3 100644 --- a/app/auth.py +++ b/app/auth.py @@ -2,20 +2,35 @@ from flask import Blueprint, flash, redirect, render_template, request, url_for from flask_login import current_user, login_user, logout_user from app import db +from app.captcha_service import verify_captcha from app.session_service import create_user_session, revoke_current_session from app.email_service import send_password_reset_email, send_welcome_email from app.folder_utils import process_pending_invites from app.models import PasswordResetToken, User, UserGroup +from app.settings_service import get_auth_public_settings, get_settings bp = Blueprint("auth", __name__, url_prefix="/auth") +def _registration_allowed(): + return get_settings().registration_enabled + + @bp.route("/register", methods=["GET", "POST"]) def register(): if current_user.is_authenticated: return redirect(url_for("cabinet.index")) + if not _registration_allowed(): + flash("Регистрация новых пользователей отключена", "error") + return redirect(url_for("auth.login")) + if request.method == "POST": + ok, captcha_msg = verify_captcha(request, "register") + if not ok: + flash(captcha_msg, "error") + return render_template("auth/register.html") + username = request.form.get("username", "").strip() email = request.form.get("email", "").strip().lower() password = request.form.get("password", "") @@ -58,7 +73,18 @@ def login(): if current_user.is_authenticated: return redirect(url_for("cabinet.index")) + auth = get_auth_public_settings() + if not auth["password_login_enabled"] and not auth["passkey_enabled"]: + flash("Вход временно недоступен", "error") + return render_template("auth/login.html") + if request.method == "POST": + if auth["password_login_enabled"]: + ok, captcha_msg = verify_captcha(request, "login") + if not ok: + flash(captcha_msg, "error") + return render_template("auth/login.html") + login = request.form.get("login", "").strip() password = request.form.get("password", "") remember = request.form.get("remember") == "on" @@ -67,7 +93,9 @@ def login(): (User.username == login) | (User.email == login.lower()) ).first() - if user is None or not user.check_password(password): + if not auth["password_login_enabled"]: + flash("Вход по паролю отключён. Используйте Passkey.", "error") + elif user is None or not user.check_password(password): flash("Неверный логин или пароль", "error") elif not user.is_active: flash("Аккаунт заблокирован", "error") @@ -94,6 +122,11 @@ def forgot_password(): return redirect(url_for("cabinet.index")) if request.method == "POST": + ok, captcha_msg = verify_captcha(request, "forgot_password") + if not ok: + flash(captcha_msg, "error") + return render_template("auth/forgot_password.html") + email = request.form.get("email", "").strip().lower() user = User.query.filter_by(email=email).first() if user: diff --git a/app/bootstrap.py b/app/bootstrap.py index 7cc0eae..7c7cd4e 100644 --- a/app/bootstrap.py +++ b/app/bootstrap.py @@ -104,6 +104,37 @@ def ensure_photo_storage_column(): db.session.commit() +def ensure_site_settings_auth_columns(): + inspector = inspect(db.engine) + if "site_settings" not in inspector.get_table_names(): + return + columns = [ + "registration_enabled BOOLEAN NOT NULL DEFAULT TRUE", + "password_login_enabled BOOLEAN NOT NULL DEFAULT TRUE", + "passkey_enabled BOOLEAN NOT NULL DEFAULT TRUE", + "webauthn_rp_id VARCHAR(255)", + "webauthn_rp_name VARCHAR(120) DEFAULT 'PhotoHost'", + "webauthn_origin VARCHAR(255)", + "captcha_provider VARCHAR(20) NOT NULL DEFAULT 'none'", + "turnstile_site_key VARCHAR(255)", + "turnstile_secret_key VARCHAR(255)", + "recaptcha_v2_site_key VARCHAR(255)", + "recaptcha_v2_secret_key VARCHAR(255)", + "recaptcha_v3_site_key VARCHAR(255)", + "recaptcha_v3_secret_key VARCHAR(255)", + "recaptcha_v3_min_score DOUBLE PRECISION NOT NULL DEFAULT 0.5", + "captcha_on_login BOOLEAN NOT NULL DEFAULT FALSE", + "captcha_on_register BOOLEAN NOT NULL DEFAULT TRUE", + "captcha_on_forgot_password BOOLEAN NOT NULL DEFAULT FALSE", + ] + for column in columns: + name = column.split()[0] + db.session.execute( + text(f"ALTER TABLE site_settings ADD COLUMN IF NOT EXISTS {name} {column[len(name) + 1:]}") + ) + db.session.commit() + + def ensure_user_privacy_columns(): inspector = inspect(db.engine) if "users" not in inspector.get_table_names(): @@ -126,6 +157,7 @@ def ensure_user_privacy_columns(): def run_schema_migrations(): ensure_schema() ensure_group_limit_columns() + ensure_site_settings_auth_columns() ensure_user_privacy_columns() from app.folders import ensure_folder_schema diff --git a/app/captcha_service.py b/app/captcha_service.py new file mode 100644 index 0000000..f1cdc6a --- /dev/null +++ b/app/captcha_service.py @@ -0,0 +1,123 @@ +import requests + +from app.settings_service import get_settings + +CAPTCHA_PROVIDERS = ("none", "turnstile", "recaptcha_v2", "recaptcha_v3") + +ENDPOINT_PAGE_MAP = { + "auth.login": "login", + "auth.register": "register", + "auth.forgot_password": "forgot_password", +} + + +def get_captcha_config(page): + settings = get_settings() + provider = (settings.captcha_provider or "none").strip() + if provider not in CAPTCHA_PROVIDERS or provider == "none": + return None + + page_flags = { + "login": settings.captcha_on_login, + "register": settings.captcha_on_register, + "forgot_password": settings.captcha_on_forgot_password, + } + if not page_flags.get(page): + return None + + config = {"provider": provider, "page": page} + if provider == "turnstile": + config["site_key"] = (settings.turnstile_site_key or "").strip() + elif provider == "recaptcha_v2": + config["site_key"] = (settings.recaptcha_v2_site_key or "").strip() + elif provider == "recaptcha_v3": + config["site_key"] = (settings.recaptcha_v3_site_key or "").strip() + config["action"] = page + + if not config.get("site_key"): + return None + return config + + +def get_captcha_config_for_request(endpoint): + page = ENDPOINT_PAGE_MAP.get(endpoint) + if not page: + return None + return get_captcha_config(page) + + +def verify_captcha(request, page): + config = get_captcha_config(page) + if not config: + return True, None + + settings = get_settings() + provider = config["provider"] + + if provider == "turnstile": + token = (request.form.get("cf-turnstile-response") or "").strip() + secret = (settings.turnstile_secret_key or "").strip() + if not token: + return False, "Подтвердите captcha" + if not secret: + return False, "Captcha не настроена (нет secret key)" + return _verify_turnstile(token, secret) + + token = (request.form.get("g-recaptcha-response") or "").strip() + if provider == "recaptcha_v2": + secret = (settings.recaptcha_v2_secret_key or "").strip() + if not token: + return False, "Подтвердите captcha" + if not secret: + return False, "Captcha не настроена (нет secret key)" + return _verify_recaptcha(token, secret) + + secret = (settings.recaptcha_v3_secret_key or "").strip() + if not token: + return False, "Подтвердите captcha" + if not secret: + return False, "Captcha не настроена (нет secret key)" + ok, msg, score = _verify_recaptcha_with_score(token, secret) + if not ok: + return False, msg + min_score = settings.recaptcha_v3_min_score if settings.recaptcha_v3_min_score is not None else 0.5 + if score < min_score: + return False, "Подозрительная активность, попробуйте снова" + return True, None + + +def _verify_turnstile(token, secret): + try: + response = requests.post( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + data={"secret": secret, "response": token}, + timeout=10, + ) + data = response.json() + except requests.RequestException: + return False, "Не удалось проверить captcha" + + if data.get("success"): + return True, None + return False, "Captcha не пройдена" + + +def _verify_recaptcha(token, secret): + ok, msg, _score = _verify_recaptcha_with_score(token, secret) + return ok, msg + + +def _verify_recaptcha_with_score(token, secret): + try: + response = requests.post( + "https://www.google.com/recaptcha/api/siteverify", + data={"secret": secret, "response": token}, + timeout=10, + ) + data = response.json() + except requests.RequestException: + return False, "Не удалось проверить captcha", 0.0 + + if data.get("success"): + return True, None, float(data.get("score") or 1.0) + return False, "Captcha не пройдена", 0.0 diff --git a/app/models.py b/app/models.py index 3d6563f..2454525 100644 --- a/app/models.py +++ b/app/models.py @@ -45,6 +45,25 @@ class SiteSettings(db.Model): smtp_from_name = db.Column(db.String(120), nullable=True, default="PhotoHost") smtp_use_tls = db.Column(db.Boolean, nullable=False, default=True) + registration_enabled = db.Column(db.Boolean, nullable=False, default=True) + password_login_enabled = db.Column(db.Boolean, nullable=False, default=True) + passkey_enabled = db.Column(db.Boolean, nullable=False, default=True) + webauthn_rp_id = db.Column(db.String(255), nullable=True) + webauthn_rp_name = db.Column(db.String(120), nullable=True, default="PhotoHost") + webauthn_origin = db.Column(db.String(255), nullable=True) + + captcha_provider = db.Column(db.String(20), nullable=False, default="none") + turnstile_site_key = db.Column(db.String(255), nullable=True) + turnstile_secret_key = db.Column(db.String(255), nullable=True) + recaptcha_v2_site_key = db.Column(db.String(255), nullable=True) + recaptcha_v2_secret_key = db.Column(db.String(255), nullable=True) + recaptcha_v3_site_key = db.Column(db.String(255), nullable=True) + recaptcha_v3_secret_key = db.Column(db.String(255), nullable=True) + recaptcha_v3_min_score = db.Column(db.Float, nullable=False, default=0.5) + captcha_on_login = db.Column(db.Boolean, nullable=False, default=False) + captcha_on_register = db.Column(db.Boolean, nullable=False, default=True) + captcha_on_forgot_password = db.Column(db.Boolean, nullable=False, default=False) + updated_at = db.Column( db.DateTime, nullable=False, diff --git a/app/passkey.py b/app/passkey.py index 0d3d591..236774f 100644 --- a/app/passkey.py +++ b/app/passkey.py @@ -7,6 +7,7 @@ from app import db from app.passkey_service import ( authentication_options, delete_passkey, + is_passkey_enabled, registration_options, verify_authentication, verify_registration, @@ -19,6 +20,8 @@ bp = Blueprint("passkey", __name__, url_prefix="/auth/passkey") @bp.route("/register/options", methods=["POST"]) @login_required def register_options(): + if not is_passkey_enabled(): + return jsonify({"error": "Passkey отключён администратором"}), 403 options = registration_options(current_user) return jsonify(json.loads(options_to_json(options))) @@ -26,6 +29,8 @@ def register_options(): @bp.route("/register/verify", methods=["POST"]) @login_required def register_verify(): + if not is_passkey_enabled(): + return jsonify({"error": "Passkey отключён администратором"}), 403 data = request.get_json(silent=True) or {} name = data.get("name", "Passkey") credential = data.get("credential") @@ -40,6 +45,8 @@ def register_verify(): @bp.route("/login/options", methods=["POST"]) def login_options(): + if not is_passkey_enabled(): + return jsonify({"error": "Passkey отключён администратором"}), 403 from app.models import User username = (request.get_json(silent=True) or {}).get("username", "").strip() @@ -61,6 +68,8 @@ def login_options(): @bp.route("/login/verify", methods=["POST"]) def login_verify(): + if not is_passkey_enabled(): + return jsonify({"error": "Passkey отключён администратором"}), 403 from app.folder_utils import process_pending_invites from app.session_service import create_user_session diff --git a/app/passkey_service.py b/app/passkey_service.py index 6c9e365..c850d7d 100644 --- a/app/passkey_service.py +++ b/app/passkey_service.py @@ -26,17 +26,32 @@ from webauthn.helpers.structs import ( from app import db from app.models import UserPasskey +from app.settings_service import get_settings + + +def is_passkey_enabled(): + settings = get_settings() + return settings.passkey_enabled def _rp_id(): + settings = get_settings() + if settings.webauthn_rp_id: + return settings.webauthn_rp_id.strip() return os.getenv("WEBAUTHN_RP_ID", "localhost") def _rp_name(): + settings = get_settings() + if settings.webauthn_rp_name: + return settings.webauthn_rp_name.strip() return os.getenv("WEBAUTHN_RP_NAME", "PhotoHost") def _origin(): + settings = get_settings() + if settings.webauthn_origin: + return settings.webauthn_origin.strip() return os.getenv("WEBAUTHN_ORIGIN", "http://localhost:8080") diff --git a/app/settings_service.py b/app/settings_service.py index f341554..6bba880 100644 --- a/app/settings_service.py +++ b/app/settings_service.py @@ -52,5 +52,42 @@ def update_settings_from_form(form): settings.smtp_from_name = form.get("smtp_from_name", "").strip() or "PhotoHost" settings.smtp_use_tls = form.get("smtp_use_tls") == "on" + settings.registration_enabled = form.get("registration_enabled") == "on" + settings.password_login_enabled = form.get("password_login_enabled") == "on" + settings.passkey_enabled = form.get("passkey_enabled") == "on" + settings.webauthn_rp_id = form.get("webauthn_rp_id", "").strip() or None + settings.webauthn_rp_name = form.get("webauthn_rp_name", "").strip() or "PhotoHost" + settings.webauthn_origin = form.get("webauthn_origin", "").strip() or None + + provider = form.get("captcha_provider", "none").strip() + if provider not in ("none", "turnstile", "recaptcha_v2", "recaptcha_v3"): + provider = "none" + settings.captcha_provider = provider + settings.turnstile_site_key = form.get("turnstile_site_key", "").strip() or None + if form.get("turnstile_secret_key", "").strip(): + settings.turnstile_secret_key = form.get("turnstile_secret_key", "").strip() + settings.recaptcha_v2_site_key = form.get("recaptcha_v2_site_key", "").strip() or None + if form.get("recaptcha_v2_secret_key", "").strip(): + settings.recaptcha_v2_secret_key = form.get("recaptcha_v2_secret_key", "").strip() + settings.recaptcha_v3_site_key = form.get("recaptcha_v3_site_key", "").strip() or None + if form.get("recaptcha_v3_secret_key", "").strip(): + settings.recaptcha_v3_secret_key = form.get("recaptcha_v3_secret_key", "").strip() + try: + settings.recaptcha_v3_min_score = max(0.0, min(1.0, float(form.get("recaptcha_v3_min_score") or 0.5))) + except ValueError: + settings.recaptcha_v3_min_score = 0.5 + settings.captcha_on_login = form.get("captcha_on_login") == "on" + settings.captcha_on_register = form.get("captcha_on_register") == "on" + settings.captcha_on_forgot_password = form.get("captcha_on_forgot_password") == "on" + db.session.commit() return settings + + +def get_auth_public_settings(): + settings = get_settings() + return { + "registration_enabled": settings.registration_enabled, + "password_login_enabled": settings.password_login_enabled, + "passkey_enabled": settings.passkey_enabled, + } diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html index ce9c99d..1965879 100644 --- a/app/templates/admin/settings.html +++ b/app/templates/admin/settings.html @@ -2,7 +2,7 @@ {% block title %}Настройки — Админка{% endblock %} {% block admin_title %}Настройки системы{% endblock %} -{% block admin_subtitle %}

S3, SFTP, FTP, SMTP и лимиты загрузки

{% endblock %} +{% block admin_subtitle %}

Авторизация, captcha, S3, SFTP, FTP, SMTP и лимиты загрузки

{% endblock %} {% block admin_content %}
@@ -16,6 +16,58 @@ +
+

Регистрация и авторизация

+ + + +
+ +
+

Passkey — RP ID и Origin

+

Для production укажите домен сайта. Значения из админки имеют приоритет над .env.

+
+
+
+
+
+
+ +
+

Captcha

+
+ + +
+ + + + +

Cloudflare Turnstile

+
+
+
+
+ +

Google reCAPTCHA v2

+
+
+
+
+ +

Google reCAPTCHA v3

+
+
+
+
+
+
+

Amazon S3 / совместимое хранилище

diff --git a/app/templates/auth/forgot_password.html b/app/templates/auth/forgot_password.html index 33701fe..248ddd7 100644 --- a/app/templates/auth/forgot_password.html +++ b/app/templates/auth/forgot_password.html @@ -14,6 +14,7 @@
+ {% include "partials/captcha.html" %}
@@ -21,3 +22,7 @@ {% endblock %} + +{% block scripts %} +{% include "partials/captcha_scripts.html" %} +{% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index e629056..e123239 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -11,6 +11,7 @@ {% include "partials/alerts.html" %} + {% if auth_settings.password_login_enabled %}
@@ -24,16 +25,25 @@ Запомнить меня + {% include "partials/captcha.html" %} + {% endif %} + {% if auth_settings.passkey_enabled %} + {% endif %}
@@ -41,5 +51,8 @@ {% endblock %} {% block scripts %} +{% include "partials/captcha_scripts.html" %} +{% if auth_settings.passkey_enabled %} +{% endif %} {% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html index 119fa67..e7daf53 100644 --- a/app/templates/auth/register.html +++ b/app/templates/auth/register.html @@ -28,6 +28,7 @@ + {% include "partials/captcha.html" %} @@ -38,3 +39,7 @@ {% endblock %} + +{% block scripts %} +{% include "partials/captcha_scripts.html" %} +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 587022d..4f9b76c 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -32,8 +32,10 @@ Выйти {% else %} Вход + {% if auth_settings.registration_enabled %} Регистрация {% endif %} + {% endif %} diff --git a/app/templates/cabinet/profile.html b/app/templates/cabinet/profile.html index 3f94fe1..fb71fbe 100644 --- a/app/templates/cabinet/profile.html +++ b/app/templates/cabinet/profile.html @@ -54,6 +54,7 @@
+ {% if auth_settings.passkey_enabled %}

Passkey

Вход без пароля через Face ID, Touch ID, Windows Hello или ключ безопасности.

@@ -82,6 +83,10 @@
+ {% else %} +

Passkey

+

Passkey отключён администратором сайта.

+ {% endif %}
@@ -149,5 +154,7 @@ {% endblock %} {% block scripts %} +{% if auth_settings.passkey_enabled %} +{% endif %} {% endblock %} diff --git a/app/templates/partials/captcha.html b/app/templates/partials/captcha.html new file mode 100644 index 0000000..347f375 --- /dev/null +++ b/app/templates/partials/captcha.html @@ -0,0 +1,12 @@ +{% if captcha_config %} +
+ {% if captcha_config.provider == 'turnstile' %} +
+ {% elif captcha_config.provider == 'recaptcha_v2' %} +
+ {% elif captcha_config.provider == 'recaptcha_v3' %} + +

Защита reCAPTCHA v3 активна

+ {% endif %} +
+{% endif %} diff --git a/app/templates/partials/captcha_scripts.html b/app/templates/partials/captcha_scripts.html new file mode 100644 index 0000000..4054722 --- /dev/null +++ b/app/templates/partials/captcha_scripts.html @@ -0,0 +1,36 @@ +{% if captcha_config %} +{% if captcha_config.provider == 'turnstile' %} + +{% elif captcha_config.provider == 'recaptcha_v2' %} + +{% elif captcha_config.provider == 'recaptcha_v3' %} + + +{% endif %} +{% endif %}