From ce37fd117ff316ad468f07a57da675c1fe6a49e4 Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Sat, 27 Jul 2024 20:49:00 -0400 Subject: [PATCH 1/4] Crete tables when using sqlite in memory --- app/util/database.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 From 889ef22cd6ee005616957c0066b2b8e00a6d5a43 Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Sat, 27 Jul 2024 20:57:56 -0400 Subject: [PATCH 2/4] Check if Discord Email is verified --- app/main.py | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/app/main.py b/app/main.py index ddef7a7..b2cf98d 100644 --- a/app/main.py +++ b/app/main.py @@ -219,24 +219,15 @@ 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"])}, - # ) if not user: + 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) From 4fb3180821c58af863253c088efd8fe976924354 Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Sat, 27 Jul 2024 21:00:34 -0400 Subject: [PATCH 3/4] Update cookie Max-Age and security settings --- app/main.py | 28 ++++++++++++++++++++++++---- app/util/settings.py | 1 + 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/app/main.py b/app/main.py index b2cf98d..443ad5d 100644 --- a/app/main.py +++ b/app/main.py @@ -161,7 +161,7 @@ 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) return rr @@ -254,11 +254,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 diff --git a/app/util/settings.py b/app/util/settings.py index 3c60bdd..70b5fe6 100644 --- a/app/util/settings.py +++ b/app/util/settings.py @@ -388,3 +388,4 @@ class Settings(BaseSettings, metaclass=SingletonBaseSettingsMeta): keycloak: KeycloakConfig = keycloak_config google_wallet: GoogleWalletConfig = google_wallet_config telemetry: Optional[TelemetryConfig] = telemetry_config + env: Optional[str] = onboard_env From 389de3378f7258f0f45252f9fc6d78ca3c5f75e4 Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Sat, 27 Jul 2024 22:09:30 -0400 Subject: [PATCH 4/4] Added hcaptcha support --- app/main.py | 47 ++++++++++++++++++++++++++++++++++++-- app/templates/error.html | 19 +++++++-------- app/templates/signup.html | 12 +++++++--- app/util/authentication.py | 29 +++++++++++++++++++++++ app/util/errors.py | 18 +++++++++++++-- app/util/settings.py | 27 ++++++++++++++++++++++ 6 files changed, 136 insertions(+), 16 deletions(-) diff --git a/app/main.py b/app/main.py index 443ad5d..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: @@ -162,6 +191,8 @@ async def oauth_transformer(redir: str = "/join/2"): rr = RedirectResponse(authorization_url, status_code=302) 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 @@ -220,7 +251,17 @@ async def oauth_transformer_new( statement = select(UserModel).where(UserModel.discord_id == discordData["id"]) user = session.exec(statement).one_or_none() + 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, @@ -290,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/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 70b5fe6..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,4 +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