Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added captacha #168

Draft
wants to merge 4 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 76 additions & 22 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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


Expand All @@ -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)

Expand Down
19 changes: 10 additions & 9 deletions app/templates/error.html
Original file line number Diff line number Diff line change
@@ -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 %}
<body>
<div class="app" id="1">
<h1>Error {{code}}</h1>
<h2>{{reason}}</h2>
<div class="app" id="1">
<h1>Error {{code}}</h1>
<h2>{{reason}}</h2>

<a class="btn" href="/"><i class="fa-solid fa-house"></i> Go Home</a>
<a class="btn" href="{{return_url}}"

Check warning

Code scanning / Semgrep OSS

Semgrep Finding: javascript.express.security.audit.xss.mustache.var-in-href.var-in-href Warning

Detected a template variable used in an anchor tag with the 'href' attribute. This allows a malicious actor to input the 'javascript:' URI and is subject to cross- site scripting (XSS) attacks. If using a relative URL, start with a literal forward slash and concatenate the URL, like this: href='/{link}'. You may also consider setting the Content Security Policy (CSP) header.

Check warning

Code scanning / Semgrep OSS

Semgrep Finding: python.django.security.audit.xss.template-href-var.template-href-var Warning

Detected a template variable used in an anchor tag with the 'href' attribute. This allows a malicious actor to input the 'javascript:' URI and is subject to cross- site scripting (XSS) attacks. Use the 'url' template tag to safely generate a URL. You may also consider setting the Content Security Policy (CSP) header.

Check warning

Code scanning / Semgrep OSS

Semgrep Finding: python.flask.security.xss.audit.template-href-var.template-href-var Warning

Detected a template variable used in an anchor tag with the 'href' attribute. This allows a malicious actor to input the 'javascript:' URI and is subject to cross- site scripting (XSS) attacks. Use 'url_for()' to safely generate a URL. You may also consider setting the Content Security Policy (CSP) header.
><i class="fa-solid fa-house"></i> {{return_text}}</a
>

<h6>{{essay}}</h6>
</div>
<h6>{{essay}}</h6>
</div>
</body>
<script type="text/javascript" src="/static/form.js"></script>
{% endblock %}
12 changes: 9 additions & 3 deletions app/templates/signup.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@
<h1>Welcome to Hack@UCF!</h1>
<h2>This wizard will help you join Hack@UCF as a dues-paying member.</h2>

<a class="btn" href="/discord/new/"
><i class="fa-brands fa-discord"></i> Link Discord</a
>
<form id="captcha-form" action="/discord/new/" method="GET">
{% if site_key %}
<div class="h-captcha" data-sitekey="{{ site_key }}"></div>
{% endif %}
<button class="btn" type="submit">
<i class="fa-brands fa-discord"></i> Link Discord
</button>
</form>

<div class="unfocus">
<h3>Use of Discord Account</h3>
Expand Down Expand Up @@ -53,6 +58,7 @@
</details>
</div>
</div>
<script src="https://hcaptcha.com/1/api.js" async defer></script>

Check warning

Code scanning / Semgrep OSS

Semgrep Finding: html.security.audit.missing-integrity.missing-integrity Warning

This tag is missing an 'integrity' subresource integrity attribute. The 'integrity' attribute allows for the browser to verify that externally hosted files (for example from a CDN) are delivered without unexpected manipulation. Without this attribute, if an attacker can modify the externally hosted resource, this could lead to XSS and other types of attacks. To prevent this, include the base64-encoded cryptographic hash of the resource (file) you’re telling the browser to fetch in the 'integrity' attribute for all externally hosted files.
</body>
<script type="text/javascript" src="/static/form.js"></script>
{% endblock %}
29 changes: 29 additions & 0 deletions app/util/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 8 additions & 1 deletion app/util/database.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import logging

# Create the database
from alembic import script
from alembic.runtime import migration
Expand All @@ -7,14 +9,19 @@
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,
connect_args={"check_same_thread": False},
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
Expand Down
18 changes: 16 additions & 2 deletions app/util/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
28 changes: 28 additions & 0 deletions app/util/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading