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:
+2
-1
@@ -23,7 +23,8 @@ DEFAULT_GROUP_MAX_PHOTOS=500
|
|||||||
ALLOW_GIT_DEPLOY=false
|
ALLOW_GIT_DEPLOY=false
|
||||||
GIT_REMOTE_URL=https://git.evilfox.cc/test2/fotohost.git
|
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_ID=localhost
|
||||||
WEBAUTHN_RP_NAME=PhotoHost
|
WEBAUTHN_RP_NAME=PhotoHost
|
||||||
WEBAUTHN_ORIGIN=http://localhost:8080
|
WEBAUTHN_ORIGIN=http://localhost:8080
|
||||||
|
|||||||
@@ -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
|
## Релиз v2.1
|
||||||
|
|
||||||
**GDPR и cookies**
|
**GDPR и cookies**
|
||||||
|
|||||||
@@ -90,6 +90,32 @@ def create_app(setup_database=True):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return {"site_banners": {}}
|
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:
|
if setup_database:
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from app.bootstrap import (
|
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 flask_login import current_user, login_user, logout_user
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
|
from app.captcha_service import verify_captcha
|
||||||
from app.session_service import create_user_session, revoke_current_session
|
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.email_service import send_password_reset_email, send_welcome_email
|
||||||
from app.folder_utils import process_pending_invites
|
from app.folder_utils import process_pending_invites
|
||||||
from app.models import PasswordResetToken, User, UserGroup
|
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")
|
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||||
|
|
||||||
|
|
||||||
|
def _registration_allowed():
|
||||||
|
return get_settings().registration_enabled
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/register", methods=["GET", "POST"])
|
@bp.route("/register", methods=["GET", "POST"])
|
||||||
def register():
|
def register():
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for("cabinet.index"))
|
return redirect(url_for("cabinet.index"))
|
||||||
|
|
||||||
|
if not _registration_allowed():
|
||||||
|
flash("Регистрация новых пользователей отключена", "error")
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
if request.method == "POST":
|
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()
|
username = request.form.get("username", "").strip()
|
||||||
email = request.form.get("email", "").strip().lower()
|
email = request.form.get("email", "").strip().lower()
|
||||||
password = request.form.get("password", "")
|
password = request.form.get("password", "")
|
||||||
@@ -58,7 +73,18 @@ def login():
|
|||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for("cabinet.index"))
|
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 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()
|
login = request.form.get("login", "").strip()
|
||||||
password = request.form.get("password", "")
|
password = request.form.get("password", "")
|
||||||
remember = request.form.get("remember") == "on"
|
remember = request.form.get("remember") == "on"
|
||||||
@@ -67,7 +93,9 @@ def login():
|
|||||||
(User.username == login) | (User.email == login.lower())
|
(User.username == login) | (User.email == login.lower())
|
||||||
).first()
|
).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")
|
flash("Неверный логин или пароль", "error")
|
||||||
elif not user.is_active:
|
elif not user.is_active:
|
||||||
flash("Аккаунт заблокирован", "error")
|
flash("Аккаунт заблокирован", "error")
|
||||||
@@ -94,6 +122,11 @@ def forgot_password():
|
|||||||
return redirect(url_for("cabinet.index"))
|
return redirect(url_for("cabinet.index"))
|
||||||
|
|
||||||
if request.method == "POST":
|
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()
|
email = request.form.get("email", "").strip().lower()
|
||||||
user = User.query.filter_by(email=email).first()
|
user = User.query.filter_by(email=email).first()
|
||||||
if user:
|
if user:
|
||||||
|
|||||||
@@ -104,6 +104,37 @@ def ensure_photo_storage_column():
|
|||||||
db.session.commit()
|
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():
|
def ensure_user_privacy_columns():
|
||||||
inspector = inspect(db.engine)
|
inspector = inspect(db.engine)
|
||||||
if "users" not in inspector.get_table_names():
|
if "users" not in inspector.get_table_names():
|
||||||
@@ -126,6 +157,7 @@ def ensure_user_privacy_columns():
|
|||||||
def run_schema_migrations():
|
def run_schema_migrations():
|
||||||
ensure_schema()
|
ensure_schema()
|
||||||
ensure_group_limit_columns()
|
ensure_group_limit_columns()
|
||||||
|
ensure_site_settings_auth_columns()
|
||||||
ensure_user_privacy_columns()
|
ensure_user_privacy_columns()
|
||||||
from app.folders import ensure_folder_schema
|
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_from_name = db.Column(db.String(120), nullable=True, default="PhotoHost")
|
||||||
smtp_use_tls = db.Column(db.Boolean, nullable=False, default=True)
|
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(
|
updated_at = db.Column(
|
||||||
db.DateTime,
|
db.DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from app import db
|
|||||||
from app.passkey_service import (
|
from app.passkey_service import (
|
||||||
authentication_options,
|
authentication_options,
|
||||||
delete_passkey,
|
delete_passkey,
|
||||||
|
is_passkey_enabled,
|
||||||
registration_options,
|
registration_options,
|
||||||
verify_authentication,
|
verify_authentication,
|
||||||
verify_registration,
|
verify_registration,
|
||||||
@@ -19,6 +20,8 @@ bp = Blueprint("passkey", __name__, url_prefix="/auth/passkey")
|
|||||||
@bp.route("/register/options", methods=["POST"])
|
@bp.route("/register/options", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def register_options():
|
def register_options():
|
||||||
|
if not is_passkey_enabled():
|
||||||
|
return jsonify({"error": "Passkey отключён администратором"}), 403
|
||||||
options = registration_options(current_user)
|
options = registration_options(current_user)
|
||||||
return jsonify(json.loads(options_to_json(options)))
|
return jsonify(json.loads(options_to_json(options)))
|
||||||
|
|
||||||
@@ -26,6 +29,8 @@ def register_options():
|
|||||||
@bp.route("/register/verify", methods=["POST"])
|
@bp.route("/register/verify", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def register_verify():
|
def register_verify():
|
||||||
|
if not is_passkey_enabled():
|
||||||
|
return jsonify({"error": "Passkey отключён администратором"}), 403
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
name = data.get("name", "Passkey")
|
name = data.get("name", "Passkey")
|
||||||
credential = data.get("credential")
|
credential = data.get("credential")
|
||||||
@@ -40,6 +45,8 @@ def register_verify():
|
|||||||
|
|
||||||
@bp.route("/login/options", methods=["POST"])
|
@bp.route("/login/options", methods=["POST"])
|
||||||
def login_options():
|
def login_options():
|
||||||
|
if not is_passkey_enabled():
|
||||||
|
return jsonify({"error": "Passkey отключён администратором"}), 403
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
|
||||||
username = (request.get_json(silent=True) or {}).get("username", "").strip()
|
username = (request.get_json(silent=True) or {}).get("username", "").strip()
|
||||||
@@ -61,6 +68,8 @@ def login_options():
|
|||||||
|
|
||||||
@bp.route("/login/verify", methods=["POST"])
|
@bp.route("/login/verify", methods=["POST"])
|
||||||
def login_verify():
|
def login_verify():
|
||||||
|
if not is_passkey_enabled():
|
||||||
|
return jsonify({"error": "Passkey отключён администратором"}), 403
|
||||||
from app.folder_utils import process_pending_invites
|
from app.folder_utils import process_pending_invites
|
||||||
from app.session_service import create_user_session
|
from app.session_service import create_user_session
|
||||||
|
|
||||||
|
|||||||
@@ -26,17 +26,32 @@ from webauthn.helpers.structs import (
|
|||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.models import UserPasskey
|
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():
|
def _rp_id():
|
||||||
|
settings = get_settings()
|
||||||
|
if settings.webauthn_rp_id:
|
||||||
|
return settings.webauthn_rp_id.strip()
|
||||||
return os.getenv("WEBAUTHN_RP_ID", "localhost")
|
return os.getenv("WEBAUTHN_RP_ID", "localhost")
|
||||||
|
|
||||||
|
|
||||||
def _rp_name():
|
def _rp_name():
|
||||||
|
settings = get_settings()
|
||||||
|
if settings.webauthn_rp_name:
|
||||||
|
return settings.webauthn_rp_name.strip()
|
||||||
return os.getenv("WEBAUTHN_RP_NAME", "PhotoHost")
|
return os.getenv("WEBAUTHN_RP_NAME", "PhotoHost")
|
||||||
|
|
||||||
|
|
||||||
def _origin():
|
def _origin():
|
||||||
|
settings = get_settings()
|
||||||
|
if settings.webauthn_origin:
|
||||||
|
return settings.webauthn_origin.strip()
|
||||||
return os.getenv("WEBAUTHN_ORIGIN", "http://localhost:8080")
|
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_from_name = form.get("smtp_from_name", "").strip() or "PhotoHost"
|
||||||
settings.smtp_use_tls = form.get("smtp_use_tls") == "on"
|
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()
|
db.session.commit()
|
||||||
return settings
|
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 title %}Настройки — Админка{% endblock %}
|
||||||
{% block admin_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 %}
|
{% block admin_content %}
|
||||||
<form method="post" class="settings-form">
|
<form method="post" class="settings-form">
|
||||||
@@ -16,6 +16,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="admin-panel" style="margin-top:24px">
|
||||||
<h2 class="admin-panel__title">Amazon S3 / совместимое хранилище</h2>
|
<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>
|
<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>
|
<label for="email">Email</label>
|
||||||
<input type="email" id="email" name="email" required autocomplete="email">
|
<input type="email" id="email" name="email" required autocomplete="email">
|
||||||
</div>
|
</div>
|
||||||
|
{% include "partials/captcha.html" %}
|
||||||
<button type="submit" class="btn btn--primary btn--full">Отправить ссылку</button>
|
<button type="submit" class="btn btn--primary btn--full">Отправить ссылку</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="auth-card__footer"><a href="{{ url_for('auth.login') }}">← Вернуться ко входу</a></p>
|
<p class="auth-card__footer"><a href="{{ url_for('auth.login') }}">← Вернуться ко входу</a></p>
|
||||||
@@ -21,3 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% include "partials/captcha_scripts.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
{% include "partials/alerts.html" %}
|
{% include "partials/alerts.html" %}
|
||||||
|
|
||||||
|
{% if auth_settings.password_login_enabled %}
|
||||||
<form method="post" class="auth-form">
|
<form method="post" class="auth-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="login">Логин или email</label>
|
<label for="login">Логин или email</label>
|
||||||
@@ -24,16 +25,25 @@
|
|||||||
<input type="checkbox" name="remember">
|
<input type="checkbox" name="remember">
|
||||||
<span>Запомнить меня</span>
|
<span>Запомнить меня</span>
|
||||||
</label>
|
</label>
|
||||||
|
{% include "partials/captcha.html" %}
|
||||||
<button type="submit" class="btn btn--primary btn--full">Войти</button>
|
<button type="submit" class="btn btn--primary btn--full">Войти</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if auth_settings.passkey_enabled %}
|
||||||
<button type="button" class="btn btn--ghost btn--full" id="passkeyLoginBtn" style="margin-top:12px">
|
<button type="button" class="btn btn--ghost btn--full" id="passkeyLoginBtn" style="margin-top:12px">
|
||||||
Войти с Passkey
|
Войти с Passkey
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<p class="auth-card__footer">
|
<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>
|
<a href="{{ url_for('auth.register') }}">Зарегистрироваться</a>
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,5 +51,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
{% include "partials/captcha_scripts.html" %}
|
||||||
|
{% if auth_settings.passkey_enabled %}
|
||||||
<script src="{{ url_for('static', filename='js/passkey.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/passkey.js') }}"></script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<label for="password2">Подтверждение пароля</label>
|
<label for="password2">Подтверждение пароля</label>
|
||||||
<input type="password" id="password2" name="password2" required minlength="6" autocomplete="new-password" placeholder="повторите пароль">
|
<input type="password" id="password2" name="password2" required minlength="6" autocomplete="new-password" placeholder="повторите пароль">
|
||||||
</div>
|
</div>
|
||||||
|
{% include "partials/captcha.html" %}
|
||||||
<button type="submit" class="btn btn--primary btn--full">Создать аккаунт</button>
|
<button type="submit" class="btn btn--primary btn--full">Создать аккаунт</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -38,3 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% include "partials/captcha_scripts.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -32,8 +32,10 @@
|
|||||||
<a href="{{ url_for('auth.logout') }}" class="nav__link">Выйти</a>
|
<a href="{{ url_for('auth.logout') }}" class="nav__link">Выйти</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('auth.login') }}" class="nav__link">Вход</a>
|
<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>
|
<a href="{{ url_for('auth.register') }}" class="nav__link nav__link--accent">Регистрация</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="auth-card auth-card--wide profile-card">
|
<div class="auth-card auth-card--wide profile-card">
|
||||||
|
{% if auth_settings.passkey_enabled %}
|
||||||
<h2 class="profile-card__title">Passkey</h2>
|
<h2 class="profile-card__title">Passkey</h2>
|
||||||
<p class="profile-card__hint">Вход без пароля через Face ID, Touch ID, Windows Hello или ключ безопасности.</p>
|
<p class="profile-card__hint">Вход без пароля через Face ID, Touch ID, Windows Hello или ключ безопасности.</p>
|
||||||
|
|
||||||
@@ -82,6 +83,10 @@
|
|||||||
<input type="text" id="passkeyName" value="Моё устройство" maxlength="120">
|
<input type="text" id="passkeyName" value="Моё устройство" maxlength="120">
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn--ghost" id="addPasskeyBtn">Добавить passkey</button>
|
<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>
|
||||||
|
|
||||||
<div class="auth-card auth-card--wide profile-card">
|
<div class="auth-card auth-card--wide profile-card">
|
||||||
@@ -149,5 +154,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
{% if auth_settings.passkey_enabled %}
|
||||||
<script src="{{ url_for('static', filename='js/passkey.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/passkey.js') }}"></script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% 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