| """ |
| src/auth/auth.py |
| Fix #9 — User authentication with Flask-Login + bcrypt password hashing. |
| """ |
|
|
| import re |
| import logging |
| import bcrypt |
| from flask_login import UserMixin |
|
|
| logger = logging.getLogger(__name__) |
|
|
| USERNAME_RE = re.compile(r"^[a-zA-Z0-9_]{3,32}$") |
|
|
|
|
| class User(UserMixin): |
| """Flask-Login compatible user model backed by SQLite.""" |
|
|
| def __init__(self, id: int, username: str, email: str): |
| self.id = id |
| self.username = username |
| self.email = email |
|
|
| |
| def get_id(self) -> str: |
| return str(self.id) |
|
|
| |
| @classmethod |
| def get(cls, user_id: int | str, db) -> "User | None": |
| row = db.get_user_by_id(int(user_id)) |
| if row: |
| return cls(row["id"], row["username"], row["email"]) |
| return None |
|
|
| @classmethod |
| def authenticate(cls, username: str, password: str, db) -> "User | None": |
| if not username or not password: |
| return None |
| row = db.get_user_by_username(username.strip()) |
| if not row: |
| |
| bcrypt.checkpw(b"dummy", bcrypt.hashpw(b"dummy", bcrypt.gensalt())) |
| return None |
| stored_hash = row["password_hash"] |
| if isinstance(stored_hash, str): |
| stored_hash = stored_hash.encode() |
| if bcrypt.checkpw(password.encode(), stored_hash): |
| return cls(row["id"], row["username"], row["email"]) |
| return None |
|
|
| @classmethod |
| def create(cls, username: str, password: str, email: str, db) -> "User": |
| """ |
| Creates a new user. Raises ValueError on validation failure. |
| """ |
| username = (username or "").strip() |
| password = (password or "").strip() |
| email = (email or "").strip() |
|
|
| if not USERNAME_RE.match(username): |
| raise ValueError("Username must be 3–32 characters: letters, numbers, underscores only.") |
| if len(password) < 8: |
| raise ValueError("Password must be at least 8 characters.") |
| if db.get_user_by_username(username): |
| raise ValueError(f"Username '{username}' is already taken.") |
|
|
| pw_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() |
| user_id = db.create_user(username, email, pw_hash) |
| logger.info("New user registered: %s (id=%d)", username, user_id) |
| return cls(user_id, username, email) |
|
|