diff --git a/app/main.py b/app/main.py index ddef7a7..92d66b9 100644 --- a/app/main.py +++ b/app/main.py @@ -3,6 +3,8 @@ from typing import Optional from urllib.parse import urlparse +import requests + # FastAPI from fastapi import Cookie, Depends, FastAPI, Request, Response, status from fastapi.responses import FileResponse, RedirectResponse @@ -144,7 +146,34 @@ async def index(request: Request, token: Optional[str] = Cookie(None)): @app.get("/discord/new/") -async def oauth_transformer(redir: str = "/join/2"): +async def oauth_transformer(request: Request, redir: str = "/join/2"): + if not Settings().env == "dev": + hcaptcha_response = request.query_params.get("h-captcha-response") + + if not hcaptcha_response: + return Errors.generate( + request, + 403, + "Captcha failed. Please try again.", + return_url="/join", + return_text="Try Again", + ) + + hcaptcha_secret = Settings().captcha.secret.get_secret_value + verify_url = "https://hcaptcha.com/siteverify" + payload = {"secret": hcaptcha_secret, "response": hcaptcha_response} + + response = requests.post(verify_url, data=payload) + result = response.json() + + if not result.get("success"): + return Errors.generate( + request, + 403, + "Captcha failed. Please try again.", + return_url="/join", + return_text="Try Again", + ) # Open redirect check hostname = urlparse(redir).netloc if hostname != "" and hostname != Settings().http.domain: @@ -161,7 +190,9 @@ async def oauth_transformer(redir: str = "/join/2"): rr = RedirectResponse(authorization_url, status_code=302) - rr.set_cookie(key="redir_endpoint", value=redir) + rr.set_cookie(key="redir_endpoint", value=redir, max_age=300) + captcha_cookie = Authentication.create_captcha_jwt() + rr.set_cookie(key="captcha", value=captcha_cookie, max_age=300) return rr @@ -219,24 +250,25 @@ async def oauth_transformer_new( # Generate a new user ID or reuse an existing one. statement = select(UserModel).where(UserModel.discord_id == discordData["id"]) user = session.exec(statement).one_or_none() - # TODO: Discuss removing - # BACKPORT: I didn't realize that Snowflakes were strings because of an integer overflow bug. - # So this will do a query for the "mistaken" value and then fix its data. - # if not query_for_id: - # logger.info("Beginning Discord ID attribute migration...") - # query_for_id = table.scan( - # FilterExpression=Attr("discord_id").eq(int(discordData["id"])) - # ) - # query_for_id = query_for_id.get("Items") - # - # if query_for_id: - # table.update_item( - # Key={"id": query_for_id[0].get("id")}, - # UpdateExpression="SET discord_id = :discord_id", - # ExpressionAttributeValues={":discord_id": str(discordData["id"])}, - # ) + captcha = request.cookies.get("captcha") if not user: + if Settings().env != "dev": + if not Authentication.validate_captcha(token=captcha) or not captcha: + return Errors.generate( + request, + 403, + "Captcha failed. Please try again. Or timed out.", + return_url="/join", + return_text="Try Again", + ) + if not discordData.get("verified"): + tr = Errors.generate( + request, + 403, + "Discord email not verfied please try again", + ) + return tr infra_email = "" discord_id = discordData["id"] Discord().join_hack_server(discord_id, token) @@ -263,11 +295,31 @@ async def oauth_transformer_new( # Create JWT. This should be the only way to issue JWTs. bearer = Authentication.create_jwt(user) rr = RedirectResponse(redir, status_code=status.HTTP_302_FOUND) - rr.set_cookie(key="token", value=bearer) - + if user.sudo: + max_age = Settings().jwt.lifetime_sudo + else: + max_age = Settings().jwt.lifetime_user + if Settings().env == "dev": + rr.set_cookie( + key="token", + value=bearer, + httponly=True, + samesite="lax", + secure=False, + max_age=max_age, + ) + else: + rr.set_cookie( + key="token", + value=bearer, + httponly=True, + samesite="lax", + secure=True, + max_age=max_age, + ) # Clear redirect cookie. rr.delete_cookie("redir_endpoint") - + rr.delete_cookie("captcha") return rr @@ -279,7 +331,9 @@ async def oauth_transformer_new( @app.get("/join/") async def join(request: Request, token: Optional[str] = Cookie(None)): if token is None: - return templates.TemplateResponse("signup.html", {"request": request}) + return templates.TemplateResponse( + "signup.html", {"request": request, "site_key": Settings().captcha.site_key} + ) else: return RedirectResponse("/join/2/", status_code=status.HTTP_302_FOUND) diff --git a/app/templates/error.html b/app/templates/error.html index 1bce965..c653196 100644 --- a/app/templates/error.html +++ b/app/templates/error.html @@ -1,15 +1,16 @@ -{% extends 'base.html' %} -{% block title %} Error {{code}} - Hack@UCF {% endblock %} -{% block content %} +{% extends 'base.html' %} {% block title %} Error {{code}} - Hack@UCF {% +endblock %} {% block content %} -
-

Error {{code}}

-

{{reason}}

+
+

Error {{code}}

+

{{reason}}

- Go Home + {{return_text}} -
{{essay}}
-
+
{{essay}}
+
{% endblock %} diff --git a/app/templates/signup.html b/app/templates/signup.html index 5bbaf1d..922bae6 100644 --- a/app/templates/signup.html +++ b/app/templates/signup.html @@ -5,9 +5,14 @@

Welcome to Hack@UCF!

This wizard will help you join Hack@UCF as a dues-paying member.

- Link Discord +
+ {% if site_key %} +
+ {% endif %} + +

Use of Discord Account

@@ -53,6 +58,7 @@

Use of Discord Account

+ {% endblock %} diff --git a/app/util/authentication.py b/app/util/authentication.py index 1612755..c5bd3dc 100644 --- a/app/util/authentication.py +++ b/app/util/authentication.py @@ -137,3 +137,32 @@ def create_jwt(user: UserModel): algorithm=Settings().jwt.algorithm, ) return bearer + + def create_captcha_jwt(): + jwtData = { + "captcha": True, + "issued": time.time(), + } + bearer = jwt.encode( + jwtData, + Settings().jwt.secret.get_secret_value(), + algorithm=Settings().jwt.algorithm, + ) + return bearer + + def validate_captcha(token: Optional[str]): + try: + user_jwt = jwt.decode( + token, + Settings().jwt.secret.get_secret_value(), + algorithms=Settings().jwt.algorithm, + ) + creation_date: float = user_jwt.get("issued", -1) + except Exception as e: + if isinstance(e, jwt.JWTError) or isinstance(e, jwt.JWTClaimsError): + return False + else: + raise + if time.time() > creation_date + 120: + return False + return True diff --git a/app/util/database.py b/app/util/database.py index 6b3feb3..1c369ea 100644 --- a/app/util/database.py +++ b/app/util/database.py @@ -1,3 +1,5 @@ +import logging + # Create the database from alembic import script from alembic.runtime import migration @@ -7,7 +9,8 @@ from app.util.settings import Settings DATABASE_URL = Settings().database.url -# TODO remove echo=True +logger = logging.getLogger(__name__) + engine = create_engine( DATABASE_URL, # echo=True, @@ -15,6 +18,10 @@ poolclass=StaticPool, ) +if "sqlite:///:memory:" in DATABASE_URL: + SQLModel.metadata.create_all(engine) + logger.info("Tables created in SQLite in-memory database.") + def init_db(): return diff --git a/app/util/errors.py b/app/util/errors.py index 4266d63..5039f4c 100644 --- a/app/util/errors.py +++ b/app/util/errors.py @@ -7,10 +7,24 @@ class Errors: def __init__(self): super(Errors, self).__init__ - def generate(request, num=404, msg="Page not found.", essay=""): + def generate( + request, + num=404, + msg="Page not found.", + essay="", + return_url="/", + return_text="Return to home", + ): return templates.TemplateResponse( "error.html", - {"request": request, "code": num, "reason": msg, "essay": essay}, + { + "request": request, + "code": num, + "reason": msg, + "essay": essay, + "return_url": return_url, + "return_text": return_text, + }, status_code=num, ) diff --git a/app/util/settings.py b/app/util/settings.py index 3c60bdd..1201862 100644 --- a/app/util/settings.py +++ b/app/util/settings.py @@ -335,6 +335,32 @@ def check_required_fields(cls, values): logger.warn("Missing Keycloak Config") +class CaptchaConfig(BaseModel): + sitekey: Optional[SecretStr] = Field(None) + secret: Optional[SecretStr] = Field(None) + enable: Optional[bool] = Field(True) + + @model_validator(mode="after") + def check_required_fields(cls, values): + enable = values.enable + if enable: + required_fields = ["sitekey", "secret"] + for field in required_fields: + if getattr(values, field) is None: + raise ValueError( + f"Keycloak {field} is required when enable is True" + ) + return values + + +if settings.get("captcha"): + captcha_config = KeycloakConfig(**settings["captcha"]) +elif onboard_env == "dev": + captcha = CaptchaConfig(enable=False) +else: + logger.warn("Missing Captcha Config") + + class TelemetryConfig(BaseModel): url: Optional[str] = None enable: Optional[bool] = False @@ -388,3 +414,5 @@ class Settings(BaseSettings, metaclass=SingletonBaseSettingsMeta): keycloak: KeycloakConfig = keycloak_config google_wallet: GoogleWalletConfig = google_wallet_config telemetry: Optional[TelemetryConfig] = telemetry_config + captcha: Optional[CaptchaConfig] = captcha_config + env: Optional[str] = onboard_env