diff --git a/poetry.lock b/poetry.lock index 5fba1b224..23f738269 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1317,6 +1317,27 @@ files = [ {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] +[[package]] +name = "pyjwt" +version = "2.12.1" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"}, + {file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"}, +] + +[package.dependencies] +typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] + [[package]] name = "pyopenssl" version = "25.0.0" @@ -1951,4 +1972,4 @@ brotli = ["brotli"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "637c808ca7e717a872f26b077fadf360e774dccb1b2944f66ee1c8ce2c5ec656" +content-hash = "6a241b148f5ffad2ce368107489fd86bd573ff7ec1219563d15c4e9a0313130a" diff --git a/pyproject.toml b/pyproject.toml index fde77eafb..d2d00776c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ django-redis = "^5.4.0" redis = "^6.4.0" mysqlclient = "^2.2.4" psutil = "^7.1.3" +pyjwt = "^2.12.1" [tool.poetry.group.dev.dependencies] djlint = "^1.34.1" diff --git a/web/forms.py b/web/forms.py index 96489d524..98f70c5e3 100644 --- a/web/forms.py +++ b/web/forms.py @@ -1,9 +1,11 @@ +import logging import re from typing import ClassVar from urllib.parse import parse_qs, urlparse import bleach from allauth.account.forms import LoginForm, SignupForm +from allauth.socialaccount.forms import SignupForm as SocialSignupForm from captcha.fields import CaptchaField from cryptography.fernet import Fernet from django import forms @@ -72,6 +74,7 @@ __all__ = [ "UserRegistrationForm", + "SocialUserRegistrationForm", "ProfileForm", "ChallengeSubmissionForm", "CourseCreationForm", @@ -115,6 +118,7 @@ ] fernet = Fernet(settings.SECURE_MESSAGE_KEY) +INVALID_REFERRAL_CODE_MSG = "Invalid referral code. Please check and try again." class TailwindWidgetMixin: @@ -263,7 +267,7 @@ def clean_referral_code(self): referral_code = self.cleaned_data.get("referral_code") if referral_code: if not Profile.objects.filter(referral_code=referral_code).exists(): - raise forms.ValidationError("Invalid referral code. Please check and try again.") + raise forms.ValidationError(INVALID_REFERRAL_CODE_MSG) return referral_code def save(self, request): @@ -295,7 +299,10 @@ def save(self, request): # Handle referral code if provided. referral_code = self.cleaned_data.get("referral_code") if referral_code: - handle_referral(user, referral_code) + try: + handle_referral(user, referral_code) + except Exception: + logging.getLogger(__name__).exception("Failed to process referral for user %s", user.pk) # Ensure email verification is sent from allauth.account.models import EmailAddress @@ -307,6 +314,79 @@ def save(self, request): return user +class SocialUserRegistrationForm(SocialSignupForm): + """Custom social signup form that collects onboarding fields used in standard registration.""" + + first_name = forms.CharField( + max_length=30, + required=True, + widget=TailwindInput(attrs={"placeholder": "First Name"}), + ) + last_name = forms.CharField( + max_length=30, + required=True, + widget=TailwindInput(attrs={"placeholder": "Last Name"}), + ) + is_teacher = forms.BooleanField( + required=False, + label="Register as a teacher", + widget=TailwindCheckboxInput(), + ) + referral_code = forms.CharField( + max_length=20, + required=False, + widget=TailwindInput(attrs={"placeholder": "Enter referral code"}), + help_text="Optional - Enter a referral code if you have one", + ) + how_did_you_hear_about_us = forms.CharField( + max_length=500, + required=False, + widget=TailwindTextarea( + attrs={"rows": 2, "placeholder": "How did you hear about us? You can enter text or a link."} + ), + help_text="Optional - Tell us how you found us. You can enter text or a link.", + ) + captcha = CaptchaField(widget=TailwindCaptchaTextInput) + is_profile_public = forms.TypedChoiceField( + required=True, + choices=(("True", "Public"), ("False", "Private")), + coerce=lambda x: x == "True", + widget=forms.RadioSelect, + label="Profile Visibility", + help_text="Select whether your profile details will be public or private.", + ) + + def clean_referral_code(self): + referral_code = self.cleaned_data.get("referral_code") + if referral_code and not Profile.objects.filter(referral_code=referral_code).exists(): + raise forms.ValidationError(INVALID_REFERRAL_CODE_MSG) + return referral_code + + def save(self, request) -> User: + user = super().save(request) + + user.first_name = self.cleaned_data.get("first_name", "") + user.last_name = self.cleaned_data.get("last_name", "") + user.save() + + user.profile.is_profile_public = self.cleaned_data.get("is_profile_public") + user.profile.how_did_you_hear_about_us = self.cleaned_data.get("how_did_you_hear_about_us", "") + + if self.cleaned_data.get("is_teacher"): + user.profile.is_teacher = True + + user.profile.save() + + referral_code = self.cleaned_data.get("referral_code") + if referral_code: + try: + handle_referral(user, referral_code) + except Exception: + logging.getLogger(__name__).exception("Failed to process referral for user %s", user.pk) + + return user + + class TailwindInput(forms.widgets.Input, TailwindWidgetMixin): def __init__(self, *args, **kwargs): kwargs.setdefault("attrs", {}).update( diff --git a/web/settings.py b/web/settings.py index 4ddcae8bb..45b009e58 100644 --- a/web/settings.py +++ b/web/settings.py @@ -6,7 +6,7 @@ import environ import sentry_sdk from cryptography.fernet import Fernet -from django.core.exceptions import DisallowedHost +from django.core.exceptions import DisallowedHost, ImproperlyConfigured from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.logging import LoggingIntegration @@ -14,18 +14,28 @@ env = environ.Env() -env_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env") - +MESSAGE_ENCRYPTION_KEY_REQUIRED_MSG = "MESSAGE_ENCRYPTION_KEY must be set in production" +GOOGLE_OAUTH_CREDENTIALS_REQUIRED_MSG = "GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set in production" -# Set encryption key for secure messaging; in production, this must come from the environment -MESSAGE_ENCRYPTION_KEY = env.str("MESSAGE_ENCRYPTION_KEY", default=Fernet.generate_key()).strip() -SECURE_MESSAGE_KEY = MESSAGE_ENCRYPTION_KEY +env_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env") if os.path.exists(env_file): environ.Env.read_env(env_file) else: print("No .env file found.") +EARLY_ENVIRONMENT = env.str("ENVIRONMENT", default="development") +EARLY_DEBUG = EARLY_ENVIRONMENT == "development" or "test" in sys.argv + +# Set encryption key for secure messaging; in production, this must come from the environment +MESSAGE_ENCRYPTION_KEY = env.str("MESSAGE_ENCRYPTION_KEY", default="").strip() +if not MESSAGE_ENCRYPTION_KEY: + if EARLY_DEBUG or "collectstatic" in sys.argv: + MESSAGE_ENCRYPTION_KEY = Fernet.generate_key().decode() + else: + raise ImproperlyConfigured(MESSAGE_ENCRYPTION_KEY_REQUIRED_MSG) +SECURE_MESSAGE_KEY = MESSAGE_ENCRYPTION_KEY + # Re-initialize / initialize Sentry AFTER environment variables are loaded so DSN is present here. SENTRY_DSN = env.str("SENTRY_DSN", default="") if SENTRY_DSN: @@ -142,6 +152,8 @@ "channels", "allauth", "allauth.account", + "allauth.socialaccount", + "allauth.socialaccount.providers.google", "captcha", "markdownx", "web", @@ -304,6 +316,10 @@ "signup": "web.forms.UserRegistrationForm", "login": "web.forms.CustomLoginForm", } +SOCIALACCOUNT_FORMS = { + "signup": "web.forms.SocialUserRegistrationForm", +} +SOCIALACCOUNT_AUTO_SIGNUP = False LANGUAGE_CODE = "en" @@ -533,3 +549,24 @@ CSRF_COOKIE_SECURE = env.bool("CSRF_COOKIE_SECURE", default=not DEBUG) SESSION_COOKIE_SECURE = env.bool("SESSION_COOKIE_SECURE", default=not DEBUG) GITHUB_WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET", "") + +# Validate Google OAuth credentials +google_client_id = env.str("GOOGLE_CLIENT_ID", default="") +google_client_secret = env.str("GOOGLE_CLIENT_SECRET", default="") +if not DEBUG and (not google_client_id or not google_client_secret): + raise ImproperlyConfigured(GOOGLE_OAUTH_CREDENTIALS_REQUIRED_MSG) + +SOCIALACCOUNT_PROVIDERS = { + "google": { + "EMAIL_AUTHENTICATION": True, + "APP": { + "client_id": google_client_id, + "secret": google_client_secret, + }, + "SCOPE": ["profile", "email"], + "AUTH_PARAMS": {"access_type": "online"}, + } +} + +SOCIALACCOUNT_EMAIL_VERIFICATION = "mandatory" +SOCIALACCOUNT_EMAIL_REQUIRED = True diff --git a/web/static/images/google-icon.svg b/web/static/images/google-icon.svg new file mode 100644 index 000000000..80ab84bd7 --- /dev/null +++ b/web/static/images/google-icon.svg @@ -0,0 +1,28 @@ + + + + + Google-color + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/templates/account/login.html b/web/templates/account/login.html index 38ac4da1e..6ed1cd023 100644 --- a/web/templates/account/login.html +++ b/web/templates/account/login.html @@ -1,7 +1,9 @@ {% extends "allauth/layouts/base.html" %} {% load static %} +{% load i18n %} {% load account %} +{% load socialaccount %} {% block extra_head %} {{ block.super }} @@ -86,6 +88,25 @@

+
+
+
+
+ {% trans "Or continue with" %} +
+ + + + Google + {% trans "Sign in with Google" %} +

Don't have an account? diff --git a/web/templates/account/signup.html b/web/templates/account/signup.html index 7720a6a97..6cccedd67 100644 --- a/web/templates/account/signup.html +++ b/web/templates/account/signup.html @@ -1,6 +1,8 @@ {% extends "allauth/layouts/base.html" %} {% load static %} +{% load i18n %} +{% load socialaccount %} {% block extra_head %} {{ block.super }} @@ -248,6 +250,25 @@

+ +
+
+
+
+
+ {% trans "Or sign up with" %} +
+
+ + + Google + {% trans "Sign up with Google" %} +