🔒 Repository is read-only – file editing is disabled.

PaganLinux/pagan-web-v2/blueprints/auth_bp.py main

365 linii Raw ← Powrót
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