File size: 5,807 Bytes
f44391a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
"""OmniParse AI — Auth: bcrypt hashing, HttpOnly session cookies, password policy, HIBP check."""

import re, secrets, hashlib, bcrypt
from datetime import datetime, timedelta, timezone
from config import (
    BCRYPT_ROUNDS, MIN_PASSWORD_LENGTH, REQUIRE_MIXED_CASE, REQUIRE_DIGIT,
    REQUIRE_SPECIAL, BANNED_PASSWORDS, SESSION_TTL_HOURS, COOKIE_NAME,
    COOKIE_SECURE, COOKIE_HTTPONLY, COOKIE_SAMESITE,
)
from database import (
    get_user_by_email, create_user as db_create_user, create_session,
    get_session, delete_session, get_user_by_id,
)

# ── Password hashing ────────────────────────────────────────────────────────
def hash_password(plain: str) -> str:
    return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt(rounds=BCRYPT_ROUNDS)).decode("utf-8")

def verify_password(plain: str, hashed: str) -> bool:
    try: return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
    except (ValueError, TypeError): return False

# ── Password policy ─────────────────────────────────────────────────────────
def _sha1(s: str) -> str: return hashlib.sha1(s.encode("utf-8")).hexdigest().upper()

def check_password_strength(pw: str) -> list[str]:
    errs = []
    if len(pw) < MIN_PASSWORD_LENGTH:
        errs.append(f"At least {MIN_PASSWORD_LENGTH} characters required.")
    if REQUIRE_MIXED_CASE and (pw.lower() == pw or pw.upper() == pw):
        errs.append("Must contain uppercase and lowercase letters.")
    if REQUIRE_DIGIT and not re.search(r"\d", pw):
        errs.append("Must contain at least one digit.")
    if REQUIRE_SPECIAL and not re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:\'",.<>?/~`]', pw):
        errs.append("Must contain at least one special character (!@#$…).")
    if pw.lower() in BANNED_PASSWORDS:
        errs.append("Password is too common. Choose something stronger.")
    if re.search(r"(.)\1{4,}", pw):
        errs.append("Must not contain 5+ repeated characters.")
    return errs

def check_password_leaked(pw: str) -> bool:
    """k-anonymity check via HIBP range API. Returns True if leaked."""
    try:
        import requests
        s = _sha1(pw); prefix, suffix = s[:5], s[5:]
        resp = requests.get(
            f"https://api.pwnedpasswords.com/range/{prefix}",
            timeout=5, headers={"User-Agent": "OmniParse-AI"},
        )
        if resp.status_code != 200: return False
        return any(line.split(":")[0] == suffix for line in resp.text.splitlines())
    except Exception: return False  # fail open

def validate_password_full(pw: str) -> tuple[bool, list[str]]:
    errs = check_password_strength(pw)
    if errs: return False, errs
    if check_password_leaked(pw):
        errs.append("This password appeared in known data breaches. Choose another.")
        return False, errs
    return True, []

# ── Session management ──────────────────────────────────────────────────────
def generate_session_token() -> str:
    return secrets.token_urlsafe(48)

def create_user_session(user_id: int) -> str:
    token = generate_session_token()
    expires = (datetime.now(timezone.utc) + timedelta(hours=SESSION_TTL_HOURS)).isoformat()
    create_session(user_id, token, expires)
    return token

def validate_session_token(token: str | None) -> dict | None:
    if not token: return None
    s = get_session(token)
    return get_user_by_id(s["user_id"]) if s else None

def set_session_cookie(response, token: str) -> None:
    response.set_cookie(
        key=COOKIE_NAME, value=token, httponly=COOKIE_HTTPONLY,
        secure=COOKIE_SECURE, samesite=COOKIE_SAMESITE,
        max_age=SESSION_TTL_HOURS * 3600, path="/",
    )

def clear_session_cookie(response) -> None:
    response.delete_cookie(key=COOKIE_NAME, path="/", httponly=COOKIE_HTTPONLY,
                            secure=COOKIE_SECURE, samesite=COOKIE_SAMESITE)

# ── Auth actions ────────────────────────────────────────────────────────────
def register_user(email: str, name: str, password: str) -> tuple[dict | None, str | None]:
    email = email.strip().lower()
    if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email):
        return None, "Please enter a valid email address."
    name = name.strip()
    if len(name) < 2:  return None, "Name must be at least 2 characters."
    if len(name) > 100: return None, "Name must be 100 characters or fewer."
    if not re.match(r"^[a-zA-ZÀ-ž\s\-']+$", name):
        return None, "Name contains invalid characters."
    valid, errs = validate_password_full(password)
    if not valid: return None, " ".join(errs)
    if get_user_by_email(email):
        return None, "An account with this email already exists."
    user = db_create_user(email, name, hash_password(password))
    return (user, None) if user else (None, "Account creation failed. Try again.")

def login_user(email: str, password: str) -> tuple[dict | None, str | None]:
    email = email.strip().lower()
    if not email or not password: return None, "Enter both email and password."
    user = get_user_by_email(email)
    # constant-time defense against user enumeration:
    if not user:
        bcrypt.checkpw(password.encode("utf-8"), bcrypt.gensalt(rounds=BCRYPT_ROUNDS))
        return None, "Invalid email or password."
    if not verify_password(password, user["password_hash"]):
        return None, "Invalid email or password."
    return user, None