Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
846ea7f
Setup fixture for mock database with user inserted
codesungrape Aug 13, 2025
99751e9
Refactor fixture using exisitng test_app mock database
codesungrape Aug 13, 2025
4fe49bd
Install and initialize Flask-Bcrypt globally in app
codesungrape Aug 14, 2025
a5ae537
Use Flask-Bcrypt in mock_user_data fixture; standardize password key
codesungrape Aug 14, 2025
e863158
Add /auth/login test suite covering success, failures, and edge cases
codesungrape Aug 14, 2025
6fea4f4
Implement /login route with JWT authentication using PyJWT
codesungrape Aug 14, 2025
f4f7116
Run formatting
codesungrape Aug 14, 2025
692e051
Standardize password key in seed_users.py and related tests
codesungrape Aug 14, 2025
1453502
Add minimal Flask app & route to isolate decorator
codesungrape Aug 15, 2025
0e9ee60
Add test for auth header missing & malformed
codesungrape Aug 15, 2025
d983fe4
Add tests 4 jwt.decode errors with monkeypatch + unittest.mock patch
codesungrape Aug 15, 2025
e334ba9
Add tests for invalid user_id in token and missing user in DB
codesungrape Aug 15, 2025
72284ce
Add test for require_jwt decorator happy path
codesungrape Aug 15, 2025
64e88c3
Add require_jwt decorator: validate Bearer token and attach user
codesungrape Aug 15, 2025
e80878a
Silence Pylint errors- WIP
codesungrape Aug 15, 2025
4a53143
Add fallback key for testing; fix Copilot typo.
codesungrape Aug 17, 2025
3d7e3b3
Add fallback key for testing in login_user()
codesungrape Aug 17, 2025
709a4bc
Drop default key; set SECRET_KEY via test_app fixture
codesungrape Aug 17, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from flask_pymongo import PyMongo

from app.config import Config
from app.extensions import mongo
from app.extensions import bcrypt, mongo


def create_app(test_config=None):
Expand All @@ -20,6 +20,8 @@ def create_app(test_config=None):

# Connect Pymongo to our specific app instance
mongo.init_app(app)
# Connect Flask-BCrypt
bcrypt.init_app(app)

# Import blueprints inside the factory
from app.routes.auth_routes import \
Expand Down
2 changes: 2 additions & 0 deletions app/extensions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Module for Flask extensions."""

from flask_bcrypt import Bcrypt
from flask_pymongo import PyMongo

# Createempty PyMongo extension object globally
# This way, we can import it in other files and avoid a code smell: tighly-coupled, cyclic error
mongo = PyMongo()
bcrypt = Bcrypt()
58 changes: 49 additions & 9 deletions app/routes/auth_routes.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
# pylint: disable=cyclic-import
"""Routes for authorization for the JWT upgrade"""

import bcrypt
import datetime

import jwt
from email_validator import EmailNotValidError, validate_email
from flask import Blueprint, jsonify, request
from flask import Blueprint, current_app, jsonify, request
Copy link

Copilot AI Aug 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Import organization: datetime and jwt imports should be grouped together as they are both third-party libraries

Copilot uses AI. Check for mistakes.
from werkzeug.exceptions import BadRequest

from app.extensions import mongo
from app.extensions import bcrypt, mongo

auth_bp = Blueprint("auth_bp", __name__, url_prefix="/auth")

Expand Down Expand Up @@ -45,24 +47,20 @@ def register_user():
return jsonify({"message": "Invalid JSON format"}), 400

# Check for Duplicate User
# Easy access with Flask_PyMongo's 'mongo'
if mongo.db.users.find_one({"email": email}):
return jsonify({"message": "Email is already registered"}), 409

# Password Hashing
# Generate a salt and hash the password
# result is a byte object representing the final hash
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
hashed_password = bcrypt.generate_password_hash(password).decode("utf-8")

# Database Insertion
user_id = mongo.db.users.insert_one(
{
"email": email,
# The hash is stored as a string in the DB
"password_hash": hashed_password.decode("utf-8"),
"password": hashed_password,
}
).inserted_id
print(user_id)

# Prepare response
return (
Expand All @@ -74,3 +72,45 @@ def register_user():
),
201,
)


# ----- LOGIN -------
@auth_bp.route("/login", methods=["POST"])
def login_user():
"""Authenticates a user and returns a JWT"""
# 1. Get the user's credentials from the request body
data = request.get_json()

if not data or not data.get("email") or not data.get("password"):
return jsonify({"error": "Email and password are required"}), 400

email = data.get("email")
password = data.get("password")

# 2. Find the user in the DB
user = mongo.db.users.find_one({"email": email})

# 3. Verify the user and password
if not user or not bcrypt.check_password_hash(user["password"], password):
return jsonify({"error": "Invalid credentials"}), 401

# 4. Generate the JWT payload
payload = {
"sub": str(user["_id"]), # sub (subject)- standard claim for user ID
"iat": datetime.datetime.now(
datetime.UTC
), # iat (issued at)- when token was created
"exp": datetime.datetime.now(datetime.UTC)
+ datetime.timedelta(hours=24), # expiration
}

# 5. Encode the token with our app's SECRET_KEY
try:
token = jwt.encode(
payload,
current_app.config["SECRET_KEY"],
algorithm="HS256", # the standard signing algorithm
)
return jsonify({"token": token}), 200
except jwt.PyJWTError:
return jsonify({"error": "Token generation failed"}), 500
68 changes: 68 additions & 0 deletions app/utils/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# pylint: disable=too-many-return-statements
"""
This module provides decorators for Flask routes, including JWT authentication.
"""
import functools
import jwt
from flask import current_app, g, jsonify, request
from bson.objectid import ObjectId
from bson.errors import InvalidId
from app.extensions import mongo


def require_jwt(f):
"""Protects routes by verifying JWT tokens in the
'Authorization: Bearer <token>' header, decoding and validating the token,
and attaching the authenticated user to the request context.
"""

@functools.wraps(f)
def decorated_function(*args, **kwargs):
# 1. Get Authorization header
auth_header = request.headers.get("Authorization", "")
if not auth_header:
return jsonify({"error": "Authorization header missing"}), 401

# 2. Expect exactly: "Bearer <token>" (case-insensitive)
parts = auth_header.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
return jsonify({"error": "Malformed Authorization header"}), 401

token = parts[1]

# 3. Decode & verify JWT
try:
payload = jwt.decode(
token,
current_app.config["SECRET_KEY"],
algorithms=["HS256"],
# options={"require": ["exp", "sub"]} # optional: force required claims
)
except jwt.ExpiredSignatureError:
return jsonify({"error": "Token has expired"}), 401
except jwt.InvalidTokenError:
return jsonify({"error": "Invalid token. Please log in again."}), 401

# 4. Extract user id from payload
user_id = payload.get("sub")
if not user_id:
return jsonify({"error": "Token missing subject (sub) claim"}), 401

# 5. Convert to ObjectId and fetch user
try:
oid = ObjectId(user_id)
except (InvalidId, TypeError):
return jsonify({"error": "Invalid user id in token"}), 401

# Exclude sensitive fields such as password
user = mongo.db.users.find_one({"_id": oid}, {"password": 0})
if not user:
return jsonify({"error": "User not found"}), 401

# 6. Attach safe user object to request context
g.current_user = user

# 7. Call original route
return f(*args, **kwargs)

return decorated_function
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ black
isort
flask_pymongo
flask-bcrypt
email-validator
email-validator
PyJWT
4 changes: 2 additions & 2 deletions scripts/seed_users.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Seeding user_data script"""

import os
import json
import os

import bcrypt

Expand Down Expand Up @@ -39,7 +39,7 @@ def seed_users(users_to_seed: list) -> str:

# insert to new user
mongo.db.users.insert_one(
{"email": email, "password_hash": hashed_password.decode("utf-8")}
{"email": email, "password": hashed_password.decode("utf-8")}
)
count += 1
print(f"Created user: {email}")
Expand Down
43 changes: 42 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
"""
from unittest.mock import patch

import bcrypt
import mongomock
import pytest

from app import create_app, mongo
from app import create_app
from app.datastore.mongo_db import get_book_collection
from app.extensions import bcrypt, mongo


@pytest.fixture(name="_insert_book_to_db")
Expand Down Expand Up @@ -73,6 +75,7 @@ def test_app():
"TESTING": True,
"TRAP_HTTP_EXCEPTIONS": True,
"API_KEY": "test-key-123",
"SECRET_KEY": "a-secure-key-for-testing-only",
"MONGO_URI": "mongodb://localhost:27017/",
"DB_NAME": "test_database",
"COLLECTION_NAME": "test_books",
Expand Down Expand Up @@ -134,3 +137,41 @@ def users_db_setup(test_app): # pylint: disable=redefined-outer-name
with test_app.app_context():
users_collection = mongo.db.users
users_collection.delete_many({})


TEST_USER_ID = "6154b3a3e4a5b6c7d8e9f0a1"
PLAIN_PASSWORD = "a-secure-password"


@pytest.fixture(scope="session") # because this data never changes
def mock_user_data():
"""Provides a dictionary of a test user's data, with a hashed password."""
# Use Flask-Bcrypt's function to CREATE the hash.
hashed_password = bcrypt.generate_password_hash(PLAIN_PASSWORD).decode("utf-8")

return {
"_id": TEST_USER_ID,
"email": "testuser@example.com",
"password": hashed_password,
}


@pytest.fixture
def seeded_user_in_db(
test_app, mock_user_data, users_db_setup
): # pylint: disable=redefined-outer-name
"""
Ensures the test database is clean and contains exactly one predefined user.
Depends on:
- test_app: To get the application context and correct mongo.db object
- mock_user_data: To get the user data to insert.
- users_db_Setup: To ensure the users collection is empty before seeding.
"""
_ = users_db_setup

with test_app.app_context():
mongo.db.users.insert_one(mock_user_data)

# yield the user data in case a test needs it
# but often we just need the side-effect of the user being in the DB
yield mock_user_data
4 changes: 2 additions & 2 deletions tests/scripts/test_seed_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ def test_seed_users_successfully(test_app):
assert admin_user is not None

# Verify the password was hashed
assert admin_user["password_hash"] != "AdminPassword123"
assert admin_user["password"] != "AdminPassword123"
assert bcrypt.checkpw(
b"AdminPassword123", admin_user["password_hash"].encode("utf-8")
b"AdminPassword123", admin_user["password"].encode("utf-8")
)
assert "Successfully seeded 2 users" in result_message

Expand Down
Loading