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/") 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/", 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