Release v2.2: admin auth settings, Passkey RP ID, Cloudflare and Google captcha
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 (
|
||||
|
||||
+34
-1
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block title %}Настройки — Админка{% endblock %}
|
||||
{% block admin_title %}Настройки системы{% endblock %}
|
||||
{% block admin_subtitle %}<p class="admin-main__subtitle">S3, SFTP, FTP, SMTP и лимиты загрузки</p>{% endblock %}
|
||||
{% block admin_subtitle %}<p class="admin-main__subtitle">Авторизация, captcha, S3, SFTP, FTP, SMTP и лимиты загрузки</p>{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<form method="post" class="settings-form">
|
||||
@@ -16,6 +16,58 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-panel" style="margin-top:24px">
|
||||
<h2 class="admin-panel__title">Регистрация и авторизация</h2>
|
||||
<label class="form-checkbox"><input type="checkbox" name="registration_enabled" {% if settings.registration_enabled %}checked{% endif %}><span>Разрешить регистрацию</span></label>
|
||||
<label class="form-checkbox"><input type="checkbox" name="password_login_enabled" {% if settings.password_login_enabled %}checked{% endif %}><span>Вход по паролю</span></label>
|
||||
<label class="form-checkbox"><input type="checkbox" name="passkey_enabled" {% if settings.passkey_enabled %}checked{% endif %}><span>Passkey (WebAuthn)</span></label>
|
||||
</div>
|
||||
|
||||
<div class="admin-panel" style="margin-top:24px">
|
||||
<h2 class="admin-panel__title">Passkey — RP ID и Origin</h2>
|
||||
<p class="folder-hint">Для production укажите домен сайта. Значения из админки имеют приоритет над <code>.env</code>.</p>
|
||||
<div class="settings-grid">
|
||||
<div class="form-group"><label>RP ID (домен)</label><input type="text" name="webauthn_rp_id" value="{{ settings.webauthn_rp_id or '' }}" placeholder="example.com"></div>
|
||||
<div class="form-group"><label>RP Name</label><input type="text" name="webauthn_rp_name" value="{{ settings.webauthn_rp_name or 'PhotoHost' }}"></div>
|
||||
<div class="form-group"><label>Origin (полный URL)</label><input type="text" name="webauthn_origin" value="{{ settings.webauthn_origin or '' }}" placeholder="https://example.com"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-panel" style="margin-top:24px">
|
||||
<h2 class="admin-panel__title">Captcha</h2>
|
||||
<div class="form-group">
|
||||
<label for="captcha_provider">Провайдер</label>
|
||||
<select id="captcha_provider" name="captcha_provider">
|
||||
<option value="none" {% if settings.captcha_provider == 'none' %}selected{% endif %}>Отключена</option>
|
||||
<option value="turnstile" {% if settings.captcha_provider == 'turnstile' %}selected{% endif %}>Cloudflare Turnstile</option>
|
||||
<option value="recaptcha_v2" {% if settings.captcha_provider == 'recaptcha_v2' %}selected{% endif %}>Google reCAPTCHA v2</option>
|
||||
<option value="recaptcha_v3" {% if settings.captcha_provider == 'recaptcha_v3' %}selected{% endif %}>Google reCAPTCHA v3</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="form-checkbox"><input type="checkbox" name="captcha_on_login" {% if settings.captcha_on_login %}checked{% endif %}><span>На странице входа</span></label>
|
||||
<label class="form-checkbox"><input type="checkbox" name="captcha_on_register" {% if settings.captcha_on_register %}checked{% endif %}><span>На странице регистрации</span></label>
|
||||
<label class="form-checkbox"><input type="checkbox" name="captcha_on_forgot_password" {% if settings.captcha_on_forgot_password %}checked{% endif %}><span>На сбросе пароля</span></label>
|
||||
|
||||
<h3 class="admin-panel__subtitle" style="margin-top:16px">Cloudflare Turnstile</h3>
|
||||
<div class="settings-grid">
|
||||
<div class="form-group"><label>Site Key</label><input type="text" name="turnstile_site_key" value="{{ settings.turnstile_site_key or '' }}"></div>
|
||||
<div class="form-group"><label>Secret Key</label><input type="password" name="turnstile_secret_key" placeholder="оставьте пустым, если не меняете"></div>
|
||||
</div>
|
||||
|
||||
<h3 class="admin-panel__subtitle" style="margin-top:16px">Google reCAPTCHA v2</h3>
|
||||
<div class="settings-grid">
|
||||
<div class="form-group"><label>Site Key</label><input type="text" name="recaptcha_v2_site_key" value="{{ settings.recaptcha_v2_site_key or '' }}"></div>
|
||||
<div class="form-group"><label>Secret Key</label><input type="password" name="recaptcha_v2_secret_key" placeholder="оставьте пустым, если не меняете"></div>
|
||||
</div>
|
||||
|
||||
<h3 class="admin-panel__subtitle" style="margin-top:16px">Google reCAPTCHA v3</h3>
|
||||
<div class="settings-grid">
|
||||
<div class="form-group"><label>Site Key</label><input type="text" name="recaptcha_v3_site_key" value="{{ settings.recaptcha_v3_site_key or '' }}"></div>
|
||||
<div class="form-group"><label>Secret Key</label><input type="password" name="recaptcha_v3_secret_key" placeholder="оставьте пустым, если не меняете"></div>
|
||||
<div class="form-group"><label>Мин. score (0–1)</label><input type="number" step="0.1" min="0" max="1" name="recaptcha_v3_min_score" value="{{ settings.recaptcha_v3_min_score or 0.5 }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-panel" style="margin-top:24px">
|
||||
<h2 class="admin-panel__title">Amazon S3 / совместимое хранилище</h2>
|
||||
<label class="form-checkbox"><input type="checkbox" name="s3_enabled" {% if settings.s3_enabled %}checked{% endif %}><span>Включить S3</span></label>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required autocomplete="email">
|
||||
</div>
|
||||
{% include "partials/captcha.html" %}
|
||||
<button type="submit" class="btn btn--primary btn--full">Отправить ссылку</button>
|
||||
</form>
|
||||
<p class="auth-card__footer"><a href="{{ url_for('auth.login') }}">← Вернуться ко входу</a></p>
|
||||
@@ -21,3 +22,7 @@
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% include "partials/captcha_scripts.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
{% include "partials/alerts.html" %}
|
||||
|
||||
{% if auth_settings.password_login_enabled %}
|
||||
<form method="post" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="login">Логин или email</label>
|
||||
@@ -24,16 +25,25 @@
|
||||
<input type="checkbox" name="remember">
|
||||
<span>Запомнить меня</span>
|
||||
</label>
|
||||
{% include "partials/captcha.html" %}
|
||||
<button type="submit" class="btn btn--primary btn--full">Войти</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if auth_settings.passkey_enabled %}
|
||||
<button type="button" class="btn btn--ghost btn--full" id="passkeyLoginBtn" style="margin-top:12px">
|
||||
Войти с Passkey
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<p class="auth-card__footer">
|
||||
<a href="{{ url_for('auth.forgot_password') }}">Забыли пароль?</a> ·
|
||||
{% if auth_settings.password_login_enabled %}
|
||||
<a href="{{ url_for('auth.forgot_password') }}">Забыли пароль?</a>
|
||||
{% if auth_settings.registration_enabled %} · {% endif %}
|
||||
{% endif %}
|
||||
{% if auth_settings.registration_enabled %}
|
||||
<a href="{{ url_for('auth.register') }}">Зарегистрироваться</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,5 +51,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% include "partials/captcha_scripts.html" %}
|
||||
{% if auth_settings.passkey_enabled %}
|
||||
<script src="{{ url_for('static', filename='js/passkey.js') }}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<label for="password2">Подтверждение пароля</label>
|
||||
<input type="password" id="password2" name="password2" required minlength="6" autocomplete="new-password" placeholder="повторите пароль">
|
||||
</div>
|
||||
{% include "partials/captcha.html" %}
|
||||
<button type="submit" class="btn btn--primary btn--full">Создать аккаунт</button>
|
||||
</form>
|
||||
|
||||
@@ -38,3 +39,7 @@
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% include "partials/captcha_scripts.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -32,8 +32,10 @@
|
||||
<a href="{{ url_for('auth.logout') }}" class="nav__link">Выйти</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}" class="nav__link">Вход</a>
|
||||
{% if auth_settings.registration_enabled %}
|
||||
<a href="{{ url_for('auth.register') }}" class="nav__link nav__link--accent">Регистрация</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
</div>
|
||||
|
||||
<div class="auth-card auth-card--wide profile-card">
|
||||
{% if auth_settings.passkey_enabled %}
|
||||
<h2 class="profile-card__title">Passkey</h2>
|
||||
<p class="profile-card__hint">Вход без пароля через Face ID, Touch ID, Windows Hello или ключ безопасности.</p>
|
||||
|
||||
@@ -82,6 +83,10 @@
|
||||
<input type="text" id="passkeyName" value="Моё устройство" maxlength="120">
|
||||
</div>
|
||||
<button type="button" class="btn btn--ghost" id="addPasskeyBtn">Добавить passkey</button>
|
||||
{% else %}
|
||||
<h2 class="profile-card__title">Passkey</h2>
|
||||
<p class="profile-card__hint">Passkey отключён администратором сайта.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="auth-card auth-card--wide profile-card">
|
||||
@@ -149,5 +154,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if auth_settings.passkey_enabled %}
|
||||
<script src="{{ url_for('static', filename='js/passkey.js') }}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{% if captcha_config %}
|
||||
<div class="form-group captcha-widget">
|
||||
{% if captcha_config.provider == 'turnstile' %}
|
||||
<div class="cf-turnstile" data-sitekey="{{ captcha_config.site_key }}"></div>
|
||||
{% elif captcha_config.provider == 'recaptcha_v2' %}
|
||||
<div class="g-recaptcha" data-sitekey="{{ captcha_config.site_key }}"></div>
|
||||
{% elif captcha_config.provider == 'recaptcha_v3' %}
|
||||
<input type="hidden" name="g-recaptcha-response" id="recaptchaV3Token" value="">
|
||||
<p class="folder-hint">Защита reCAPTCHA v3 активна</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% if captcha_config %}
|
||||
{% if captcha_config.provider == 'turnstile' %}
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
{% elif captcha_config.provider == 'recaptcha_v2' %}
|
||||
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
|
||||
{% elif captcha_config.provider == 'recaptcha_v3' %}
|
||||
<script src="https://www.google.com/recaptcha/api.js?render={{ captcha_config.site_key }}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
function bindRecaptchaV3() {
|
||||
const form = document.querySelector(".auth-form");
|
||||
const tokenInput = document.getElementById("recaptchaV3Token");
|
||||
if (!form || !tokenInput || !window.grecaptcha) return;
|
||||
|
||||
form.addEventListener("submit", function (event) {
|
||||
if (tokenInput.value) return;
|
||||
event.preventDefault();
|
||||
grecaptcha.ready(function () {
|
||||
grecaptcha.execute("{{ captcha_config.site_key }}", { action: "{{ captcha_config.action }}" })
|
||||
.then(function (token) {
|
||||
tokenInput.value = token;
|
||||
form.submit();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (window.grecaptcha) {
|
||||
bindRecaptchaV3();
|
||||
} else {
|
||||
window.addEventListener("load", bindRecaptchaV3);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
Reference in New Issue
Block a user