Skip to content

Commit 3a93a83

Browse files
committed
Merge remote-tracking branch 'origin/dev' into dark-mode
2 parents 713e0ef + de65341 commit 3a93a83

22 files changed

+307
-357
lines changed

.env.example

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,6 @@ REVIEW_DRIVE_ID=?
1010
REVIEW_DRIVE_EMAIL=?
1111
REVIEW_DRIVE_PASSWORD=?
1212

13-
# social-auth-app-django library
14-
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY=?
15-
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET=?
16-
1713
# database
1814
DB_USER=?
1915
DB_NAME=?
@@ -34,3 +30,10 @@ AWS_SECRET_ACCESS_KEY=?
3430
# s3
3531
AWS_STORAGE_BUCKET_NAME=?
3632
AWS_S3_REGION_NAME=?
33+
34+
# AWS Cognito
35+
COGNITO_USER_POOL_ID=?
36+
COGNITO_APP_CLIENT_ID=?
37+
COGNITO_APP_CLIENT_SECRET=?
38+
COGNITO_DOMAIN=?
39+
COGNITO_REGION_NAME=?

.github/workflows/aws.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,12 @@ env:
2222
AWS_RDS_PASSWORD: ${{ secrets.AWS_DS_PASSWORD }}
2323
AWS_RDS_HOST: ${{ secrets.AWS_RDS_HOST }}
2424
AWS_RDS_PORT: ${{ secrets.AWS_RDS_PORT }}
25-
# social-auth-app-django
26-
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY: ${{ secrets.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY }}
27-
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET: ${{ secrets.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET }}
25+
# AWS Cognito
26+
COGNITO_USER_POOL_ID: ${{ secrets.COGNITO_USER_POOL_ID }}
27+
COGNITO_APP_CLIENT_ID: ${{ secrets.COGNITO_APP_CLIENT_ID }}
28+
COGNITO_APP_CLIENT_SECRET: ${{ secrets.COGNITO_APP_CLIENT_SECRET }}
29+
COGNITO_DOMAIN: ${{ secrets.COGNITO_DOMAIN }}
30+
COGNITO_REGION_NAME: ${{ secrets.COGNITO_REGION_NAME }}
2831
# email for account verification
2932
EMAIL_HOST_USER: ${{ secrets.EMAIL_HOST_USER }}
3033
EMAIL_HOST_PASSWORD: ${{ secrets.EMAIL_HOST_PASSWORD }}

.github/workflows/ci.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ env:
2121
DB_PASSWORD: postgres # default password
2222
DB_HOST: localhost # required for GitHub Actions
2323
DB_PORT: 5432 # default port
24-
# social-auth-app-django
25-
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY: ${{ secrets.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY }}
26-
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET: ${{ secrets.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET }}
24+
# AWS Cognito
25+
COGNITO_USER_POOL_ID: ${{ secrets.COGNITO_USER_POOL_ID }}
26+
COGNITO_APP_CLIENT_ID: ${{ secrets.COGNITO_APP_CLIENT_ID }}
27+
COGNITO_APP_CLIENT_SECRET: ${{ secrets.COGNITO_APP_CLIENT_SECRET }}
28+
COGNITO_DOMAIN: ${{ secrets.COGNITO_DOMAIN }}
29+
COGNITO_REGION_NAME: ${{ secrets.COGNITO_REGION_NAME }}
2730
# email for account verification
2831
EMAIL_HOST_USER: ${{ secrets.EMAIL_HOST_USER }}
2932
EMAIL_HOST_PASSWORD: ${{ secrets.EMAIL_HOST_PASSWORD }}

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ social-auth-app-django~=5.4.0
2727
tqdm~=4.66.1
2828
types-tqdm~=4.66.0
2929
uWSGI~=2.0.28
30+
python-jose~=3.4.0

tcf_core/cognito_middleware.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Middleware for handling Cognito authentication"""
2+
3+
import logging
4+
5+
logger = logging.getLogger(__name__)
6+
7+
8+
class CognitoAuthMiddleware: # pylint: disable=too-few-public-methods
9+
"""
10+
Middleware that processes Cognito tokens in HTTP requests
11+
12+
This middleware checks for tokens in cookies or headers and authenticates users
13+
"""
14+
15+
def __init__(self, get_response):
16+
self.get_response = get_response
17+
18+
def __call__(self, request):
19+
# The middleware only runs for the callback path
20+
# The actual authentication is handled in the view
21+
response = self.get_response(request)
22+
return response

tcf_core/settings/base.py

Lines changed: 16 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@
4242
"django.contrib.messages",
4343
# "collectfast",
4444
"django.contrib.staticfiles",
45-
"social_django",
4645
"cachalot", # TODO: add Redis?
4746
"storages",
4847
"rest_framework",
@@ -113,6 +112,7 @@
113112
"django.contrib.auth.middleware.AuthenticationMiddleware",
114113
"django.contrib.messages.middleware.MessageMiddleware",
115114
"django.middleware.clickjacking.XFrameOptionsMiddleware",
115+
"tcf_core.cognito_middleware.CognitoAuthMiddleware",
116116
"tcf_core.settings.handle_exceptions_middleware.HandleExceptionsMiddleware",
117117
"tcf_core.settings.record_middleware.RecordMiddleware",
118118
]
@@ -178,45 +178,25 @@
178178

179179
# social-auth-app-django settings.
180180

181+
# AWS Cognito Configuration
182+
COGNITO_USER_POOL_ID = env.str("COGNITO_USER_POOL_ID")
183+
COGNITO_APP_CLIENT_ID = env.str("COGNITO_APP_CLIENT_ID")
184+
COGNITO_APP_CLIENT_SECRET = env.str("COGNITO_APP_CLIENT_SECRET")
185+
COGNITO_DOMAIN = env.str("COGNITO_DOMAIN")
186+
COGNITO_REGION_NAME = env.str("COGNITO_REGION_NAME")
187+
188+
# These should match exactly what you configured in Cognito
189+
COGNITO_REDIRECT_URI = "/cognito-callback"
190+
COGNITO_LOGOUT_URI = "/"
191+
192+
# Replace social auth backends with custom Cognito backend
181193
AUTHENTICATION_BACKENDS = (
182-
"social_core.backends.google.GoogleOAuth2",
183-
"social_core.backends.email.EmailAuth",
194+
"tcf_website.auth_backends.CognitoBackend",
184195
"django.contrib.auth.backends.ModelBackend",
185196
)
186-
# TODO: Look into options like SOCIAL_AUTH_LOGIN_ERROR_URL or LOGIN_ERROR_URL
187-
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = env.str("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY")
188-
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = env.str("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET")
189-
SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS = ["virginia.edu"]
190-
SOCIAL_AUTH_LOGIN_REDIRECT_URL = reverse_lazy("browse")
191-
192-
SOCIAL_AUTH_EMAIL_FORM_URL = "/"
193-
EMAIL_VALIDATION_URL = "email_verification"
194-
SOCIAL_AUTH_EMAIL_VALIDATION_FUNCTION = "tcf_core.auth_pipeline.validate_email"
195-
SOCIAL_AUTH_EMAIL_AUTH_WHITELISTED_DOMAINS = ["virginia.edu"]
196197

197-
WHITELISTED_DOMAINS = ["virginia.edu"]
198-
199-
SOCIAL_AUTH_GOOGLE_OAUTH2_LOGIN_URL = reverse_lazy(
200-
"social:begin", args=["google-oauth2"]
201-
)
202-
SOCIAL_AUTH_RAISE_EXCEPTIONS = False
203-
SOCIAL_AUTH_PIPELINE = (
204-
"tcf_core.auth_pipeline.password_validation",
205-
"social_core.pipeline.social_auth.social_details",
206-
"social_core.pipeline.social_auth.social_uid",
207-
"tcf_core.auth_pipeline.auth_allowed",
208-
"social_core.pipeline.social_auth.social_user",
209-
"social_core.pipeline.user.get_username",
210-
"tcf_core.auth_pipeline.mail_validation",
211-
"social_core.pipeline.social_auth.associate_by_email",
212-
"tcf_core.auth_pipeline.collect_extra_info",
213-
"tcf_core.auth_pipeline.create_user",
214-
"social_core.pipeline.social_auth.associate_user",
215-
"tcf_core.auth_pipeline.check_user_password",
216-
"social_core.pipeline.social_auth.load_extra_data",
217-
"social_core.pipeline.user.user_details",
218-
)
219-
SOCIAL_AUTH_USER_MODEL = "tcf_website.User"
198+
# Login URL for redirecting unauthenticated users
199+
LOGIN_URL = reverse_lazy("login")
220200

221201
AUTH_USER_MODEL = "tcf_website.User"
222202

tcf_website/auth_backends.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Custom authentication backend for AWS Cognito."""
2+
3+
import json
4+
import logging
5+
import time
6+
from urllib.request import urlopen
7+
8+
import jose.jwk
9+
import jose.jwt
10+
from jose.exceptions import JWKError
11+
from django.conf import settings
12+
from django.contrib.auth import get_user_model
13+
14+
User = get_user_model()
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class CognitoBackend:
19+
"""
20+
Authentication backend for validating user against AWS Cognito
21+
"""
22+
23+
def authenticate(self, request, token=None):
24+
"""
25+
Authenticate the user using the token provided by Cognito
26+
"""
27+
if token is None:
28+
return None
29+
30+
try:
31+
# Validate the token
32+
claims = self.validate_token(token)
33+
if not claims:
34+
return None
35+
36+
# Get user info from the claims
37+
email = claims.get("email")
38+
username = email.split("@")[0]
39+
40+
if not username:
41+
return None
42+
43+
# Get or create the user
44+
user, created = User.objects.get_or_create(
45+
username=username,
46+
defaults={
47+
"email": email,
48+
"computing_id": username,
49+
"first_name": claims.get("given_name", ""),
50+
"last_name": claims.get("family_name", ""),
51+
},
52+
)
53+
54+
# Update user attributes if they've changed
55+
if not created:
56+
updated = False
57+
for attr, value in {
58+
"email": email,
59+
"computing_id": username,
60+
"first_name": claims.get("given_name", user.first_name),
61+
"last_name": claims.get("family_name", user.last_name),
62+
}.items():
63+
if getattr(user, attr) != value:
64+
setattr(user, attr, value)
65+
updated = True
66+
67+
if updated:
68+
user.save()
69+
70+
return user
71+
except (ValueError, jose.JWTError) as e:
72+
logger.exception("Error authenticating with Cognito: %s", str(e))
73+
return None
74+
75+
def get_user(self, user_id):
76+
"""
77+
Retrieve a user by their ID
78+
"""
79+
try:
80+
return User.objects.get(pk=user_id)
81+
except User.DoesNotExist:
82+
return None
83+
84+
def validate_token(self, token):
85+
"""
86+
Validate the JWT token from Cognito
87+
"""
88+
# Get the JWKs from Cognito
89+
jwks_url = (
90+
f"https://cognito-idp.{settings.COGNITO_REGION_NAME}.amazonaws.com/"
91+
f"{settings.COGNITO_USER_POOL_ID}/.well-known/jwks.json"
92+
)
93+
94+
try:
95+
with urlopen(jwks_url) as response:
96+
jwks = json.loads(response.read())
97+
98+
# Extract the key ID from the token
99+
headers = jose.jwt.get_unverified_header(token)
100+
kid = headers["kid"]
101+
102+
# Find the matching key
103+
key = next((k for k in jwks["keys"] if k["kid"] == kid), None)
104+
if not key:
105+
logger.warning("Matching key not found for kid %s", kid)
106+
return None
107+
108+
# Construct the public key
109+
public_key = jose.jwk.construct(key)
110+
111+
# Verify the token
112+
claims = jose.jwt.decode(
113+
token,
114+
public_key,
115+
algorithms=["RS256"],
116+
audience=settings.COGNITO_APP_CLIENT_ID,
117+
options={
118+
"verify_at_hash": False,
119+
},
120+
)
121+
122+
# Check if token is expired
123+
current_time = time.time()
124+
if current_time > claims["exp"]:
125+
logger.warning("Token has expired")
126+
return None
127+
128+
return claims
129+
except (jose.JWTError, JWKError, KeyError, ValueError) as e:
130+
logger.exception("Error validating token: %s", str(e))
131+
return None

tcf_website/models/models.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,23 @@
1111
from django.core.paginator import EmptyPage, Page, Paginator
1212
from django.core.validators import MaxValueValidator, MinValueValidator
1313
from django.db import models
14-
from django.db.models import (Avg, Case, CharField, Exists, ExpressionWrapper,
15-
F, FloatField, OuterRef, Q, QuerySet, Subquery,
16-
Sum, Value, When, fields)
14+
from django.db.models import (
15+
Avg,
16+
Case,
17+
CharField,
18+
Exists,
19+
ExpressionWrapper,
20+
F,
21+
FloatField,
22+
OuterRef,
23+
Q,
24+
QuerySet,
25+
Subquery,
26+
Sum,
27+
Value,
28+
When,
29+
fields,
30+
)
1731
from django.db.models.functions import Abs, Cast, Coalesce, Concat, Round
1832

1933
# pylint: disable=line-too-long
Binary file not shown.

tcf_website/templates/department/department.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ <h4 class="card-title">
4343
</div>
4444
<div class="pagination">
4545
{% if paginated_courses.has_previous %}
46+
<a href="?page={{ 1 }}{% if query_params %}&{{ query_params }}{% endif %}">&lt;&lt;</a>
4647
<a href="?page={{ paginated_courses.previous_page_number }}{% if query_params %}&{{ query_params }}{% endif %}">&lt;</a>
4748
{% endif %}
4849

@@ -84,6 +85,7 @@ <h4 class="card-title">
8485

8586
{% if paginated_courses.has_next %}
8687
<a href="?page={{ paginated_courses.next_page_number }}{% if query_params %}&{{ query_params }}{% endif %}">&gt;</a>
88+
<a href="?page={{ paginated_courses.paginator.num_pages }}{% if query_params %}&{{ query_params }}{% endif %}">&gt;&gt;</a>
8789
{% endif %}
8890
</div>
8991
</div>

0 commit comments

Comments
 (0)