Skip to content

Commit 16337b4

Browse files
authored
Add /auth/login endpoint for jwt securitzation (#13)
* Refactor existing test_app fixture for mock database with user inserted * Install and initialize Flask-Bcrypt globally in app * Use Flask-Bcrypt in mock_user_data fixture; standardize password key * Add /auth/login test suite covering success, failures, and edge cases - Test valid login returns 200 with correct JWT payload - Test wrong password returns 401 with 'Invalid credentials' - Test non-existent user returns 401 - Test missing data returns 400 with specific error message - Parametrize payload variations for missing data cases - Test PyJWT encoding error returns 500 with proper message * Implement /login route with JWT authentication using PyJWT - Validate email and password from request - Look up user in MongoDB - Verify password hash with Flask-Bcrypt - Generate JWT with sub, iat, and exp claims - Handle PyJWTError with 500 response * Add minimal Flask app & route to isolate decorator * Add tests 4 jwt.decode errors with monkeypatch + unittest.mock patch * Add tests for invalid user_id in token and missing user in DB * Add test for require_jwt decorator happy path * Add require_jwt decorator: validate Bearer token and attach user * Drop default key; set SECRET_KEY via test_app fixture
1 parent e30f56f commit 16337b4

File tree

10 files changed

+541
-24
lines changed

10 files changed

+541
-24
lines changed

app/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from flask_pymongo import PyMongo
77

88
from app.config import Config
9-
from app.extensions import mongo
9+
from app.extensions import bcrypt, mongo
1010

1111

1212
def create_app(test_config=None):
@@ -20,6 +20,8 @@ def create_app(test_config=None):
2020

2121
# Connect Pymongo to our specific app instance
2222
mongo.init_app(app)
23+
# Connect Flask-BCrypt
24+
bcrypt.init_app(app)
2325

2426
# Import blueprints inside the factory
2527
from app.routes.auth_routes import \

app/extensions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""Module for Flask extensions."""
22

3+
from flask_bcrypt import Bcrypt
34
from flask_pymongo import PyMongo
45

56
# Createempty PyMongo extension object globally
67
# This way, we can import it in other files and avoid a code smell: tighly-coupled, cyclic error
78
mongo = PyMongo()
9+
bcrypt = Bcrypt()

app/routes/auth_routes.py

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
# pylint: disable=cyclic-import
22
"""Routes for authorization for the JWT upgrade"""
33

4-
import bcrypt
4+
import datetime
5+
6+
import jwt
57
from email_validator import EmailNotValidError, validate_email
6-
from flask import Blueprint, jsonify, request
8+
from flask import Blueprint, current_app, jsonify, request
79
from werkzeug.exceptions import BadRequest
810

9-
from app.extensions import mongo
11+
from app.extensions import bcrypt, mongo
1012

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

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

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

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

5756
# Database Insertion
5857
user_id = mongo.db.users.insert_one(
5958
{
6059
"email": email,
6160
# The hash is stored as a string in the DB
62-
"password_hash": hashed_password.decode("utf-8"),
61+
"password": hashed_password,
6362
}
6463
).inserted_id
65-
print(user_id)
6664

6765
# Prepare response
6866
return (
@@ -74,3 +72,45 @@ def register_user():
7472
),
7573
201,
7674
)
75+
76+
77+
# ----- LOGIN -------
78+
@auth_bp.route("/login", methods=["POST"])
79+
def login_user():
80+
"""Authenticates a user and returns a JWT"""
81+
# 1. Get the user's credentials from the request body
82+
data = request.get_json()
83+
84+
if not data or not data.get("email") or not data.get("password"):
85+
return jsonify({"error": "Email and password are required"}), 400
86+
87+
email = data.get("email")
88+
password = data.get("password")
89+
90+
# 2. Find the user in the DB
91+
user = mongo.db.users.find_one({"email": email})
92+
93+
# 3. Verify the user and password
94+
if not user or not bcrypt.check_password_hash(user["password"], password):
95+
return jsonify({"error": "Invalid credentials"}), 401
96+
97+
# 4. Generate the JWT payload
98+
payload = {
99+
"sub": str(user["_id"]), # sub (subject)- standard claim for user ID
100+
"iat": datetime.datetime.now(
101+
datetime.UTC
102+
), # iat (issued at)- when token was created
103+
"exp": datetime.datetime.now(datetime.UTC)
104+
+ datetime.timedelta(hours=24), # expiration
105+
}
106+
107+
# 5. Encode the token with our app's SECRET_KEY
108+
try:
109+
token = jwt.encode(
110+
payload,
111+
current_app.config["SECRET_KEY"],
112+
algorithm="HS256", # the standard signing algorithm
113+
)
114+
return jsonify({"token": token}), 200
115+
except jwt.PyJWTError:
116+
return jsonify({"error": "Token generation failed"}), 500

app/utils/decorators.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# pylint: disable=too-many-return-statements
2+
"""
3+
This module provides decorators for Flask routes, including JWT authentication.
4+
"""
5+
import functools
6+
import jwt
7+
from flask import current_app, g, jsonify, request
8+
from bson.objectid import ObjectId
9+
from bson.errors import InvalidId
10+
from app.extensions import mongo
11+
12+
13+
def require_jwt(f):
14+
"""Protects routes by verifying JWT tokens in the
15+
'Authorization: Bearer <token>' header, decoding and validating the token,
16+
and attaching the authenticated user to the request context.
17+
"""
18+
19+
@functools.wraps(f)
20+
def decorated_function(*args, **kwargs):
21+
# 1. Get Authorization header
22+
auth_header = request.headers.get("Authorization", "")
23+
if not auth_header:
24+
return jsonify({"error": "Authorization header missing"}), 401
25+
26+
# 2. Expect exactly: "Bearer <token>" (case-insensitive)
27+
parts = auth_header.split()
28+
if len(parts) != 2 or parts[0].lower() != "bearer":
29+
return jsonify({"error": "Malformed Authorization header"}), 401
30+
31+
token = parts[1]
32+
33+
# 3. Decode & verify JWT
34+
try:
35+
payload = jwt.decode(
36+
token,
37+
current_app.config["SECRET_KEY"],
38+
algorithms=["HS256"],
39+
# options={"require": ["exp", "sub"]} # optional: force required claims
40+
)
41+
except jwt.ExpiredSignatureError:
42+
return jsonify({"error": "Token has expired"}), 401
43+
except jwt.InvalidTokenError:
44+
return jsonify({"error": "Invalid token. Please log in again."}), 401
45+
46+
# 4. Extract user id from payload
47+
user_id = payload.get("sub")
48+
if not user_id:
49+
return jsonify({"error": "Token missing subject (sub) claim"}), 401
50+
51+
# 5. Convert to ObjectId and fetch user
52+
try:
53+
oid = ObjectId(user_id)
54+
except (InvalidId, TypeError):
55+
return jsonify({"error": "Invalid user id in token"}), 401
56+
57+
# Exclude sensitive fields such as password
58+
user = mongo.db.users.find_one({"_id": oid}, {"password": 0})
59+
if not user:
60+
return jsonify({"error": "User not found"}), 401
61+
62+
# 6. Attach safe user object to request context
63+
g.current_user = user
64+
65+
# 7. Call original route
66+
return f(*args, **kwargs)
67+
68+
return decorated_function

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ black
1010
isort
1111
flask_pymongo
1212
flask-bcrypt
13-
email-validator
13+
email-validator
14+
PyJWT

scripts/seed_users.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Seeding user_data script"""
22

3-
import os
43
import json
4+
import os
55

66
import bcrypt
77

@@ -39,7 +39,7 @@ def seed_users(users_to_seed: list) -> str:
3939

4040
# insert to new user
4141
mongo.db.users.insert_one(
42-
{"email": email, "password_hash": hashed_password.decode("utf-8")}
42+
{"email": email, "password": hashed_password.decode("utf-8")}
4343
)
4444
count += 1
4545
print(f"Created user: {email}")

tests/conftest.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
"""
77
from unittest.mock import patch
88

9+
import bcrypt
910
import mongomock
1011
import pytest
1112

12-
from app import create_app, mongo
13+
from app import create_app
1314
from app.datastore.mongo_db import get_book_collection
15+
from app.extensions import bcrypt, mongo
1416

1517

1618
@pytest.fixture(name="_insert_book_to_db")
@@ -73,6 +75,7 @@ def test_app():
7375
"TESTING": True,
7476
"TRAP_HTTP_EXCEPTIONS": True,
7577
"API_KEY": "test-key-123",
78+
"SECRET_KEY": "a-secure-key-for-testing-only",
7679
"MONGO_URI": "mongodb://localhost:27017/",
7780
"DB_NAME": "test_database",
7881
"COLLECTION_NAME": "test_books",
@@ -134,3 +137,41 @@ def users_db_setup(test_app): # pylint: disable=redefined-outer-name
134137
with test_app.app_context():
135138
users_collection = mongo.db.users
136139
users_collection.delete_many({})
140+
141+
142+
TEST_USER_ID = "6154b3a3e4a5b6c7d8e9f0a1"
143+
PLAIN_PASSWORD = "a-secure-password"
144+
145+
146+
@pytest.fixture(scope="session") # because this data never changes
147+
def mock_user_data():
148+
"""Provides a dictionary of a test user's data, with a hashed password."""
149+
# Use Flask-Bcrypt's function to CREATE the hash.
150+
hashed_password = bcrypt.generate_password_hash(PLAIN_PASSWORD).decode("utf-8")
151+
152+
return {
153+
"_id": TEST_USER_ID,
154+
"email": "[email protected]",
155+
"password": hashed_password,
156+
}
157+
158+
159+
@pytest.fixture
160+
def seeded_user_in_db(
161+
test_app, mock_user_data, users_db_setup
162+
): # pylint: disable=redefined-outer-name
163+
"""
164+
Ensures the test database is clean and contains exactly one predefined user.
165+
Depends on:
166+
- test_app: To get the application context and correct mongo.db object
167+
- mock_user_data: To get the user data to insert.
168+
- users_db_Setup: To ensure the users collection is empty before seeding.
169+
"""
170+
_ = users_db_setup
171+
172+
with test_app.app_context():
173+
mongo.db.users.insert_one(mock_user_data)
174+
175+
# yield the user data in case a test needs it
176+
# but often we just need the side-effect of the user being in the DB
177+
yield mock_user_data

tests/scripts/test_seed_users.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ def test_seed_users_successfully(test_app):
3737
assert admin_user is not None
3838

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

0 commit comments

Comments
 (0)