Skip to content

Commit 3e0881c

Browse files
authored
Merge pull request #62 from cuappdev/claire/auth
Add JWT authentication and user management features
2 parents a46afd6 + b77a24f commit 3e0881c

15 files changed

Lines changed: 265 additions & 10 deletions

app.py

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,47 @@
11
import logging
22
import argparse
3-
from flask import Flask, request, g
3+
import signal
4+
import sys
45
import time
6+
from datetime import datetime, timedelta
7+
8+
from dotenv import load_dotenv
9+
10+
load_dotenv()
11+
12+
from flask import Flask, request, g
13+
from flask_cors import CORS
14+
from flask_jwt_extended import JWTManager
515
from flask_graphql import GraphQLView
616
from graphene import Schema
717
from src.schema import Query, Mutation
818
from src.scrapers.games_scraper import fetch_game_schedule
919
from src.scrapers.youtube_stats import fetch_videos
1020
from src.scrapers.daily_sun_scrape import fetch_news
1121
from src.services.article_service import ArticleService
22+
from src.utils.constants import JWT_SECRET_KEY
1223
from src.utils.team_loader import TeamLoader
13-
import signal
14-
import sys
15-
from dotenv import load_dotenv
16-
17-
load_dotenv()
24+
from src.database import db
1825

1926
app = Flask(__name__)
2027

28+
# CORS: allow frontend (different origin) to call this API
29+
CORS(app, supports_credentials=True)
30+
31+
# JWT config
32+
app.config["JWT_SECRET_KEY"] = JWT_SECRET_KEY
33+
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1)
34+
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30)
35+
36+
jwt = JWTManager(app)
37+
38+
39+
@jwt.token_in_blocklist_loader
40+
def check_if_token_revoked(jwt_header, jwt_payload: dict) -> bool:
41+
"""Reject the request if the token's jti is in the blocklist (e.g. after logout)."""
42+
jti = jwt_payload["jti"]
43+
return db["token_blocklist"].find_one({"jti": jti}) is not None
44+
2145

2246
@app.before_request
2347
def start_timer():
@@ -73,7 +97,7 @@ def log_response_time(response):
7397
datefmt="%Y-%m-%d %H:%M:%S",
7498
)
7599

76-
schema = Schema(query=Query, mutation=Mutation)
100+
schema = Schema(query=Query, mutation=Mutation, auto_camelcase=True)
77101

78102

79103
def create_context():
@@ -136,6 +160,17 @@ class DefaultArgs:
136160
scheduler.init_app(app)
137161
scheduler.start()
138162

163+
@scheduler.task("interval", id="cleanse_token_blocklist", seconds=86400) # 24 hours
164+
def cleanse_token_blocklist():
165+
"""Remove expired tokens from blocklist so the collection doesn't grow forever."""
166+
from datetime import timezone
167+
from src.database import db
168+
result = db["token_blocklist"].delete_many(
169+
{"expires_at": {"$lt": datetime.now(timezone.utc)}}
170+
)
171+
if result.deleted_count:
172+
logging.info(f"Cleansed {result.deleted_count} expired token(s) from blocklist")
173+
139174
@scheduler.task("interval", id="scrape_schedules", seconds=43200) # 12 hours
140175
def scrape_schedules():
141176
logging.info("Scraping game schedules...")

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
Flask
2+
Flask-CORS
3+
Flask-JWT-Extended==4.7.1
24
Flask-GraphQL
35
graphene
46
pymongo

src/database.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ def setup_database_indexes():
9898
background=True
9999
)
100100

101+
# JWT blocklist: fast lookup by jti
102+
db["token_blocklist"].create_index([("jti", 1)], background=True)
103+
101104
print("✅ MongoDB indexes created successfully")
102105
except Exception as e:
103106
print(f"❌ Failed to create MongoDB indexes: {e}")

src/mutations/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
from .create_game import CreateGame
22
from .create_team import CreateTeam
33
from .create_youtube_video import CreateYoutubeVideo
4-
from .create_article import CreateArticle
4+
from .create_article import CreateArticle
5+
from .login_user import LoginUser
6+
from .signup_user import SignupUser
7+
from .refresh_access_token import RefreshAccessToken
8+
from .logout_user import LogoutUser
9+
from .add_favorite_game import AddFavoriteGame
10+
from .remove_favorite_game import RemoveFavoriteGame

src/mutations/add_favorite_game.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from bson import ObjectId
2+
from graphql import GraphQLError
3+
from graphene import Mutation, String, Boolean
4+
5+
from flask_jwt_extended import get_jwt_identity, jwt_required
6+
from src.database import db
7+
from src.services.game_service import GameService
8+
9+
10+
class AddFavoriteGame(Mutation):
11+
class Arguments:
12+
game_id = String(required=True, description="ID of the game to add to favorites.")
13+
14+
success = Boolean()
15+
16+
@jwt_required()
17+
def mutate(self, info, game_id):
18+
if not GameService.get_game_by_id(game_id):
19+
raise GraphQLError("Game not found.")
20+
user_id = get_jwt_identity()
21+
db["users"].update_one(
22+
{"_id": ObjectId(user_id)},
23+
{"$addToSet": {"favorite_game_ids": game_id}},
24+
)
25+
return AddFavoriteGame(success=True)

src/mutations/login_user.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from graphql import GraphQLError
2+
from graphene import Mutation, String, Field
3+
4+
from flask_jwt_extended import create_access_token, create_refresh_token
5+
from src.database import db
6+
7+
8+
class LoginUser(Mutation):
9+
class Arguments:
10+
net_id = String(required=True, description="User's net ID (e.g. Cornell netid).")
11+
12+
access_token = String()
13+
refresh_token = String()
14+
15+
def mutate(self, info, net_id):
16+
user = db["users"].find_one({"net_id": net_id})
17+
if not user:
18+
raise GraphQLError("User not found.")
19+
identity = str(user["_id"])
20+
return LoginUser(
21+
access_token=create_access_token(identity=identity),
22+
refresh_token=create_refresh_token(identity=identity),
23+
)

src/mutations/logout_user.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from datetime import datetime, timezone
2+
3+
from graphene import Mutation, Boolean
4+
5+
from flask_jwt_extended import get_jwt, jwt_required
6+
from src.database import db
7+
8+
9+
class LogoutUser(Mutation):
10+
success = Boolean()
11+
12+
@jwt_required(verify_type=False)
13+
def mutate(self, info):
14+
token = get_jwt()
15+
jti = token["jti"]
16+
exp = token["exp"]
17+
expires_at = datetime.fromtimestamp(exp, tz=timezone.utc)
18+
db["token_blocklist"].insert_one({"jti": jti, "expires_at": expires_at})
19+
return LogoutUser(success=True)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from graphene import Mutation, String
2+
3+
from flask_jwt_extended import create_access_token, get_jwt_identity, jwt_required
4+
5+
6+
class RefreshAccessToken(Mutation):
7+
new_access_token = String()
8+
9+
@jwt_required(refresh=True)
10+
def mutate(self, info):
11+
identity = get_jwt_identity()
12+
return RefreshAccessToken(
13+
new_access_token=create_access_token(identity=identity),
14+
)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from bson import ObjectId
2+
from graphene import Mutation, String, Boolean
3+
4+
from flask_jwt_extended import get_jwt_identity, jwt_required
5+
from src.database import db
6+
7+
8+
class RemoveFavoriteGame(Mutation):
9+
class Arguments:
10+
game_id = String(required=True, description="ID of the game to remove from favorites.")
11+
12+
success = Boolean()
13+
14+
@jwt_required()
15+
def mutate(self, info, game_id):
16+
user_id = get_jwt_identity()
17+
db["users"].update_one(
18+
{"_id": ObjectId(user_id)},
19+
{"$pull": {"favorite_game_ids": game_id}},
20+
)
21+
return RemoveFavoriteGame(success=True)

src/mutations/signup_user.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from graphql import GraphQLError
2+
from graphene import Mutation, String
3+
4+
from flask_jwt_extended import create_access_token, create_refresh_token
5+
from src.database import db
6+
7+
8+
class SignupUser(Mutation):
9+
class Arguments:
10+
net_id = String(required=True, description="User's net ID (e.g. Cornell netid).")
11+
name = String(required=False, description="Display name.")
12+
email = String(required=False, description="Email address.")
13+
14+
access_token = String()
15+
refresh_token = String()
16+
17+
def mutate(self, info, net_id, name=None, email=None):
18+
if db["users"].find_one({"net_id": net_id}):
19+
raise GraphQLError("Net ID already exists.")
20+
user_doc = {
21+
"net_id": net_id,
22+
"favorite_game_ids": [],
23+
}
24+
if name is not None:
25+
user_doc["name"] = name
26+
if email is not None:
27+
user_doc["email"] = email
28+
result = db["users"].insert_one(user_doc)
29+
identity = str(result.inserted_id)
30+
return SignupUser(
31+
access_token=create_access_token(identity=identity),
32+
refresh_token=create_refresh_token(identity=identity),
33+
)

0 commit comments

Comments
 (0)