🔒 Repository is read-only – file editing is disabled.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
from flask import Blueprint, render_template, request, redirect, session, flash, make_response
from database import get_db, get_setting, set_setting
from auth import hash_password, verify_password
from mail import send_email
from config.settings import BASE_URL, SMTP_PASS
from i18n import t
import totp as totp_mod
import hashlib, os, random, time
# ── Blacklista podejrzanych domen email (spam/bot) ──
_SUS_EMAILS = {
"mail.ru", "tempmail", "10minute", "guerrillamail", "yopmail", "sharklasers",
"throwamail", "tmpmail", "getnada", "mailinator", "trashmail", "fakeinbox",
"tempmailo", "moakt", "dispostable", "maildrop", "harakirimail", "deadaddress",
}
def _is_suspicious_email(email):
try:
domain = email.rsplit("@", 1)[-1].lower().split(".")[-2]
except Exception:
return True
return domain in _SUS_EMAILS
auth_bp = Blueprint("auth", __name__)
# ── Brute-force protection ──
_login_fails = {} # {ip_or_user: [(timestamp, ...)]}
_MAX_FAILS_IP = 8 # max prób z 1 IP w oknie
_MAX_FAILS_USER = 5 # max prób na 1 konto
_FAIL_WINDOW = 600 # 10 minut
def _check_brute_force(username, ip):
"""Zwraca (blocked, reason) jeśli przekroczono limit."""
now = time.time()
# Sprawdź per IP
ip_key = f"ip:{ip}"
attempts = [t for t in _login_fails.get(ip_key, []) if t > now - _FAIL_WINDOW]
_login_fails[ip_key] = attempts
if len(attempts) >= _MAX_FAILS_IP:
remaining = int(_FAIL_WINDOW - (now - min(attempts)))
return True, f"Zbyt wiele prób z tego IP. Spróbuj za {remaining}s."
# Sprawdź per user
user_key = f"user:{username}"
attempts = [t for t in _login_fails.get(user_key, []) if t > now - _FAIL_WINDOW]
_login_fails[user_key] = attempts
if len(attempts) >= _MAX_FAILS_USER:
remaining = int(_FAIL_WINDOW - (now - min(attempts)))
return True, f"Zbyt wiele prób dla konta {username}. Spróbuj za {remaining}s."
return False, ""
def _record_login_fail(username, ip):
now = time.time()
_login_fails.setdefault(f"ip:{ip}", []).append(now)
_login_fails.setdefault(f"user:{username}", []).append(now)
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
ip = request.headers.get("X-Real-IP", request.remote_addr) or "?"
blocked, reason = _check_brute_force(username, ip)
if blocked:
flash(f"{reason}", "error")
_audit(f"LOGIN_BLOCKED {username} ip={ip}")
return render_template("auth/login.html")
db = get_db()
user = db.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
db.close()
if user and verify_password(password, user["password_hash"]):
if user["blocked"]:
flash("" + t("account_blocked"), "error")
return render_template("auth/login.html")
if not user["verified"]:
flash("" + t("account_inactive"), "error")
return render_template("auth/login.html")
# ── 2FA: sprawdź czy urządzenie jest zaufane ──
if user["totp_enabled"]:
trusted = request.cookies.get("2fa_trusted", "")
if trusted:
expected = hashlib.sha256(f"{username}:{user['totp_secret']}:trusted".encode()).hexdigest()[:32]
if trusted != expected:
trusted = ""
if not trusted:
session["_2fa_user"] = username
session["_2fa_remember"] = bool(request.form.get("remember_me"))
session["_2fa_next"] = request.args.get("next", "/")
return redirect("/login/2fa")
# ── Login complete ──
session["username"] = user["username"]
session["is_admin"] = bool(user["is_admin"])
session["email"] = user["email"] or ""
session.permanent = bool(request.form.get("remember_me"))
_login_fails.pop(f"user:{username}", None)
_login_fails.pop(f"ip:{ip}", None)
_audit(f"LOGIN_OK {username}")
flash(t("logged_in"), "ok")
return redirect(request.args.get("next", "/"))
remaining_ip = _MAX_FAILS_IP - len([t for t in _login_fails.get(f"ip:{ip}", []) if t > time.time() - _FAIL_WINDOW])
remaining_user = _MAX_FAILS_USER - len([t for t in _login_fails.get(f"user:{username}", []) if t > time.time() - _FAIL_WINDOW])
_record_login_fail(username, ip)
if remaining_user <= 2 or remaining_ip <= 3:
flash(f"{t('wrong_credentials')} {t('remaining_attempts')} {min(remaining_user, remaining_ip)}.", "error")
else:
flash(t("wrong_credentials"), "error")
_audit(f"LOGIN_FAIL {username}")
return render_template("auth/login.html")
@auth_bp.route("/login/2fa", methods=["GET", "POST"])
def login_2fa():
username = session.get("_2fa_user")
if not username:
return redirect("/login")
if request.method == "POST":
code = request.form.get("code", "").strip()
recovery = request.form.get("recovery", "").strip().upper()
db = get_db()
user = db.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
db.close()
if not user:
session.pop("_2fa_user", None)
return redirect("/login")
# Sprawdź kod odzyskiwania
if recovery:
import json
recovery_hashed = hashlib.sha256(recovery.encode()).hexdigest()
stored = json.loads(user["recovery_codes"] or "[]")
if recovery_hashed in stored:
stored.remove(recovery_hashed)
db2 = get_db()
db2.execute("UPDATE users SET recovery_codes=? WHERE username=?", (json.dumps(stored), username))
db2.commit()
db2.close()
flash(f"Użyto kodu zapasowego. Pozostało: {len(stored)}", "ok")
else:
flash("❌ Nieprawidłowy kod zapasowy", "error")
return render_template("auth/2fa.html")
elif not totp_mod.verify_totp(user["totp_secret"] or "", code):
flash("❌ Nieprawidłowy kod 2FA", "error")
return render_template("auth/2fa.html")
# Login complete
session["username"] = user["username"]
session["is_admin"] = bool(user["is_admin"])
session["email"] = user["email"] or ""
session.permanent = session.pop("_2fa_remember", False)
next_url = session.pop("_2fa_next", "/")
session.pop("_2fa_user", None)
_audit(f"LOGIN_OK {username} (2FA)")
flash(t("logged_in"), "ok")
resp = make_response(redirect(next_url))
# Trust this device for 30 days
if request.form.get("trust_device"):
trusted = hashlib.sha256(f"{username}:{user['totp_secret']}:trusted".encode()).hexdigest()[:32]
resp.set_cookie("2fa_trusted", trusted, max_age=86400*30, httponly=True, secure=True, samesite="Lax")
return resp
return render_template("auth/2fa.html")
@auth_bp.route("/logout")
def logout():
session.clear()
return redirect("/")
@auth_bp.route("/register", methods=["GET", "POST"])
def register():
registration_enabled = get_setting("registration_enabled", "1") == "1"
admin_activation_required = get_setting("admin_activation_required", "0") == "1"
if not registration_enabled:
flash("" + t("registration_disabled"), "error")
return redirect("/login")
captcha_a = random.randint(1, 9)
captcha_b = random.randint(1, 9)
# CAPTCHA nie polega na sesji – używa podpisanego tokena
captcha_answer = captcha_a + captcha_b
captcha_sig = hashlib.sha256(f"{captcha_answer}:pagan-captcha-salt".encode()).hexdigest()[:12]
# Dodatkowo: ukryte pola z a i b (zaciemnione przez XOR z solą)
captcha_a_hidden = captcha_a ^ 0x5A
captcha_b_hidden = captcha_b ^ 0xA5
if request.method == "POST":
username = request.form.get("username", "").strip()
email = request.form.get("email", "").strip()
password = request.form.get("password", "")
# ── CAPTCHA: weryfikacja z podpisanego tokena (bez sesji) ──
captcha_ok = False
try:
answer = int(request.form.get("captcha_answer", ""))
sig = request.form.get("captcha_sig", "")
# Odtwórz a i b z ukrytych pól
a_hidden = int(request.form.get("ca", "0"))
b_hidden = int(request.form.get("cb", "0"))
real_a = a_hidden ^ 0x5A
real_b = b_hidden ^ 0xA5
real_answer = real_a + real_b
expected_sig = hashlib.sha256(f"{real_answer}:pagan-captcha-salt".encode()).hexdigest()[:12]
if answer == real_answer and sig == expected_sig and 2 <= real_answer <= 18:
captcha_ok = True
except (ValueError, TypeError):
pass
# Fallback: sesja (dla starych sesji)
if not captcha_ok:
try:
answer = int(request.form.get("captcha_answer", ""))
real_answer = int(session.get("captcha_a", 0)) + int(session.get("captcha_b", 0))
if answer == real_answer and real_answer > 0:
captcha_ok = True
except (ValueError, TypeError):
pass
if not captcha_ok:
flash("❌ " + t("captcha_error"), "error")
return render_template("auth/register.html",
captcha_a=captcha_a, captcha_b=captcha_b,
captcha_sig=captcha_sig, captcha_a_hidden=captcha_a_hidden,
captcha_b_hidden=captcha_b_hidden,
registration_enabled=registration_enabled,
admin_activation_required=admin_activation_required)
if len(username) < 3 or len(password) < 5:
flash("Nazwa min. 3 znaki, hasło min. 5", "error")
elif "@" not in email:
flash("Podaj poprawny adres e-mail", "error")
elif _is_suspicious_email(email):
flash("❌ " + t("email_blocked"), "error")
else:
db = get_db()
try:
if admin_activation_required:
db.execute("INSERT INTO users (username, password_hash, email, verified) VALUES (?,?,?,0)",
(username, hash_password(password), email))
db.commit()
db.close()
_audit(f"REGISTER {username} {email} (oczekuje na aktywację admina)")
flash("✅ " + t("account_created_admin"), "ok")
return redirect("/login")
verify_token = hashlib.sha256(os.urandom(32)).hexdigest()
auto_verify = not SMTP_PASS
db.execute("INSERT INTO users (username, password_hash, email, verified, verify_token) VALUES (?,?,?,?,?)",
(username, hash_password(password), email, 1 if auto_verify else 0, verify_token))
db.commit()
if auto_verify:
db.close()
_audit(f"REGISTER {username} {email} (auto-verify)")
flash("✅ " + t("account_created"), "ok")
return redirect("/login")
link = f"{BASE_URL}/verify/{verify_token}"
body = f"""Witaj {username}!
Dziękujemy za rejestrację na Pagan Linux.
Aby aktywować swoje konto, kliknij w link poniżej:
{link}
Link jest ważny przez 24 godziny.
Jeśli nie zakładałeś konta – zignoruj tę wiadomość.
Pozdrawiamy,
Zespół Pagan Linux
"""
sent = send_email(email, "Potwierdzenie rejestracji – Pagan Linux", body)
db.close()
_audit(f"REGISTER {username} {email} (wysłano e-mail)" if sent else f"REGISTER {username} {email} (błąd wysyłki e-maila)")
if sent:
flash("✅ Konto utworzone! Sprawdź pocztę i kliknij link aktywacyjny.", "ok")
else:
flash("⚠ Konto utworzone, ale nie udało się wysłać e-maila. Skontaktuj się z administratorem.", "error")
return redirect("/login")
except Exception as e:
if "UNIQUE" in str(e).upper():
flash("Nazwa lub email już zajęte", "error")
else:
flash(f"Błąd rejestracji: {e}", "error")
db.close()
return render_template("auth/register.html",
captcha_a=captcha_a, captcha_b=captcha_b,
captcha_sig=captcha_sig, captcha_a_hidden=captcha_a_hidden,
captcha_b_hidden=captcha_b_hidden,
registration_enabled=registration_enabled, admin_activation_required=admin_activation_required)
return render_template("auth/register.html",
captcha_a=captcha_a, captcha_b=captcha_b,
captcha_sig=captcha_sig, captcha_a_hidden=captcha_a_hidden,
captcha_b_hidden=captcha_b_hidden,
registration_enabled=registration_enabled, admin_activation_required=admin_activation_required)
@auth_bp.route("/verify/<token>")
def verify_email(token):
db = get_db()
user = db.execute("SELECT * FROM users WHERE verify_token=? AND verified=0", (token,)).fetchone()
if user:
db.execute("UPDATE users SET verified=1, verify_token=NULL WHERE id=?", (user["id"],))
db.commit()
db.close()
flash("✅ " + t("email_verified"), "ok")
return redirect("/login")
db.close()
flash("❌ " + t("verify_link_invalid"), "error")
return redirect("/")
# ── Password reset ──
_RESET_TOKENS = {} # {token: (username, expiry_timestamp)}
@auth_bp.route("/forgot-password", methods=["GET", "POST"])
def forgot_password():
if request.method == "POST":
email = request.form.get("email", "").strip()
if "@" not in email:
flash("Podaj poprawny adres e-mail", "error")
return render_template("auth/forgot_password.html")
db = get_db()
user = db.execute("SELECT username, email FROM users WHERE email=?", (email,)).fetchone()
db.close()
if user:
token = hashlib.sha256(os.urandom(32)).hexdigest()
_RESET_TOKENS[token] = (user["username"], time.time() + 3600) # 1h ważności
reset_url = f"{BASE_URL}/reset-password/{token}"
body = f"""Cześć {user["username"]},\n\nOtrzymaliśmy prośbę o reset hasła do konta PaganOS.\nKliknij link poniżej aby ustawić nowe hasło (ważny 1 godzinę):\n\n{reset_url}\n\nJeśli to nie Ty, zignoruj tę wiadomość.\n"""
send_email(email, "Reset hasła – Pagan Linux", body)
# Zawsze pokazuj ten sam komunikat (nie zdradzaj czy email istnieje)
flash(t("password_reset_sent"), "ok")
return redirect("/login")
return render_template("auth/forgot_password.html")
@auth_bp.route("/reset-password/<token>", methods=["GET", "POST"])
def reset_password(token):
entry = _RESET_TOKENS.get(token)
if not entry or entry[1] < time.time():
_RESET_TOKENS.pop(token, None)
flash("❌ " + t("reset_link_invalid"), "error")
return redirect("/login")
username = entry[0]
if request.method == "POST":
pw = request.form.get("password", "")
if len(pw) < 5:
flash("Hasło musi mieć min. 5 znaków", "error")
return render_template("auth/reset_password.html", token=token)
db = get_db()
db.execute("UPDATE users SET password_hash=? WHERE username=?", (hash_password(pw), username))
db.commit()
db.close()
_RESET_TOKENS.pop(token, None)
_audit(f"PASSWORD_RESET {username}")
flash("✅ " + t("password_changed"), "ok")
return redirect("/login")
return render_template("auth/reset_password.html", token=token)
# ═══════════════ AUDIT LOG ═══════════════
_AUDIT_PATH = "/var/lib/pagan-web/audit.log"
def _audit(msg):
"""Zapisuje zdarzenie do logu audytu (bezpieczeństwo)."""
try:
ip = request.headers.get("X-Real-IP", request.remote_addr) or "?"
user = session.get("username", "?")
ts = time.strftime("%Y-%m-%d %H:%M:%S")
os.makedirs(os.path.dirname(_AUDIT_PATH), exist_ok=True)
with open(_AUDIT_PATH, "a") as f:
f.write(f"{ts} [{user}] {ip} {msg}\n")
except Exception:
pass