From a7520769d02e786291b8e609d12a15129ee72539 Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Sun, 17 Mar 2024 22:14:12 -0400 Subject: [PATCH 1/9] Reworked Config Switched config to a type validated pydantic setting class Introduces breaking changes to config file add support for bitwarden secrets manager --- config.yml | 16 +++-- index.py | 84 +++++++++++++------------- requirements.txt | 4 ++ routes/admin.py | 35 ++++++----- routes/api.py | 14 ++--- routes/infra.py | 26 ++++---- routes/stripe.py | 20 +++--- routes/wallet.py | 6 +- util/approve.py | 22 +++---- util/authentication.py | 34 +++++------ util/discord.py | 8 +-- util/email.py | 15 ++--- util/forms.py | 10 +++ util/options.py | 134 ++++++++++++++++++++++++++++++++++------- 14 files changed, 260 insertions(+), 168 deletions(-) create mode 100644 util/forms.py diff --git a/config.yml b/config.yml index 85f9838..aeebfbc 100644 --- a/config.yml +++ b/config.yml @@ -2,9 +2,8 @@ jwt: secret: algorithm: "HS256" - lifetime: # (in seconds) - user: 9072000 # 15 weeks - sudo: 86400 # 1 day + lifetime_user: 9072000 # 15 weeks + lifetime_sudo: 86400 # 1 day http: domain: join.hackucf.org @@ -28,12 +27,11 @@ discord: stripe: - api_key: "" - webhook_secret: "" - price_id: "" - url: - success: "https://join.hackucf.org/final/" - failure: "https://join.hackucf.org/pay/" + api_key: + webhook_secret: + price_id: + url_success: "https://join.hackucf.org/final/" + url_failure: "https://join.hackucf.org/pay/" aws: dynamodb: diff --git a/index.py b/index.py index b1bf11a..9e6919c 100644 --- a/index.py +++ b/index.py @@ -29,18 +29,19 @@ # Import the page rendering library from util.kennelish import Kennelish # Import options -from util.options import Options +from util.options import Settings +from util.forms import Forms ### TODO: TEMP os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "0" ### + logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(name)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S') logger = logging.getLogger(__name__) -options = Options.fetch() # Initiate FastAPI. app = FastAPI() @@ -60,9 +61,9 @@ f"""clouds: hackucf_infra: auth: - auth_url: {options.get('infra', {}).get('horizon', '')}:5000 - application_credential_id: {options.get('infra', {}).get('ad', {}).get('application_credential_id', '')} - application_credential_secret: {options.get('infra', {}).get('ad', {}).get('application_credential_secret', '')} + auth_url: {Settings().infra.horizon}:5000 + application_credential_id: {Settings().infra.application_credential_id} + application_credential_secret: {Settings().infra.application_credential_secret.get_secret_value()} region_name: "hack-ucf-0" interface: "public" identity_api_version: 3 @@ -84,15 +85,15 @@ async def index(request: Request, token: Optional[str] = Cookie(None)): infra_email = None try: - payload = jwt.decode( + user_jwt = jwt.decode( token, - options.get("jwt").get("secret"), - algorithms=options.get("jwt").get("algorithm"), + Settings().jwt.secret.get_secret_value(), + algorithms=Settings().jwt.algorithm, ) - is_full_member: bool = payload.get("is_full_member", False) - is_admin: bool = payload.get("sudo", False) - user_id: bool = payload.get("id", None) - infra_email: bool = payload.get("infra_email", None) + is_full_member: bool = user_jwt.get("is_full_member", False) + is_admin: bool = user_jwt.get("sudo", False) + user_id: bool = user_jwt.get("id", None) + infra_email: bool = user_jwt.get("infra_email", None) except Exception as e: logger.exception(e) pass @@ -119,15 +120,14 @@ async def index(request: Request, token: Optional[str] = Cookie(None)): async def oauth_transformer(redir: str = "/join/2"): # Open redirect check hostname = urlparse(redir).netloc - if hostname != "" and hostname != options.get("http", {}).get( - "domain", "my.hackucf.org" - ): + print(hostname) + if hostname != "" and hostname != Settings().http.domain: redir = "/join/2" oauth = OAuth2Session( - options.get("discord").get("client_id"), - redirect_uri=options.get("discord").get("redirect_base") + "_redir", - scope=options.get("discord").get("scope"), + Settings().discord.client_id, + redirect_uri=Settings().discord.redirect_base + "_redir", + scope=Settings().discord.scope, ) authorization_url, state = oauth.authorization_url( "https://discord.com/api/oauth2/authorize" @@ -156,7 +156,7 @@ async def oauth_transformer_new( ): # AWS dependencies dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) # Open redirect check if redir == "_redir": @@ -164,9 +164,7 @@ async def oauth_transformer_new( hostname = urlparse(redir).netloc - if hostname != "" and hostname != options.get("http", {}).get( - "domain", "my.hackucf.org" - ): + if hostname != "" and hostname != Settings().http.domain: redir = "/join/2" if code is None: @@ -179,15 +177,15 @@ async def oauth_transformer_new( # Get data from Discord oauth = OAuth2Session( - options.get("discord").get("client_id"), - redirect_uri=options.get("discord").get("redirect_base") + "_redir", - scope=options.get("discord")["scope"], + Settings().discord.client_id, + redirect_uri=Settings().discord.redirect_base + "_redir", + scope=Settings().discord.scope, ) token = oauth.fetch_token( "https://discord.com/api/oauth2/token", - client_id=options.get("discord").get("client_id"), - client_secret=options.get("discord").get("secret"), + client_id=Settings().discord.client_id, + client_secret=Settings().discord.secret.get_secret_value(), # authorization_response=code code=code, ) @@ -235,20 +233,20 @@ async def oauth_transformer_new( # Make user join the Hack@UCF Discord, if it's their first rodeo. discord_id = str(discordData["id"]) headers = { - "Authorization": f"Bot {options.get('discord', {}).get('bot_token')}", + "Authorization": f"Bot {Settings().discord.bot_token.get_secret_value()}", "Content-Type": "application/json", "X-Audit-Log-Reason": "Hack@UCF OnboardLite Bot", } put_join_guild = {"access_token": token["access_token"]} requests.put( - f"https://discordapp.com/api/guilds/{options.get('discord', {}).get('guild_id')}/members/{discord_id}", + f"https://discordapp.com/api/guilds/{Settings().discord.guild_id}/members/{discord_id}", headers=headers, data=json.dumps(put_join_guild), ) data = { "id": member_id, - "discord_id": int(discordData["id"]), + "discord_id": discordData["id"], "discord": { "email": discordData["email"], "mfa": discordData["mfa_enabled"], @@ -290,8 +288,8 @@ async def oauth_transformer_new( } bearer = jwt.encode( jwtData, - options.get("jwt").get("secret"), - algorithm=options.get("jwt").get("algorithm"), + Settings().jwt.secret.get_secret_value(), + algorithm=Settings().jwt.algorithm, ) rr = RedirectResponse(redir, status_code=status.HTTP_302_FOUND) rr.set_cookie(key="token", value=bearer) @@ -325,16 +323,16 @@ async def join(request: Request, token: Optional[str] = Cookie(None)): async def profile( request: Request, token: Optional[str] = Cookie(None), - payload: Optional[object] = {}, + user_jwt: Optional[object] = {}, ): # Get data from DynamoDB dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) - user_data = table.get_item(Key={"id": payload.get("id")}).get("Item", None) + user_data = table.get_item(Key={"id": user_jwt.get("id")}).get("Item", None) # Re-run approval workflow. - Approve.approve_member(payload.get("id")) + Approve.approve_member(user_jwt.get("id")) return templates.TemplateResponse( "profile.html", {"request": request, "user_data": user_data} @@ -351,20 +349,20 @@ async def profile( async def forms( request: Request, token: Optional[str] = Cookie(None), - payload: Optional[object] = {}, + user_jwt: Optional[object] = {}, num: str = 1, ): # AWS dependencies dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) if num == "1": return RedirectResponse("/join/", status_code=status.HTTP_302_FOUND) - data = Options.get_form_body(num) + data = Forms.get_form_body(num) # Get data from DynamoDB - user_data = table.get_item(Key={"id": payload.get("id")}).get("Item", None) + user_data = table.get_item(Key={"id": user_jwt.get("id")}).get("Item", None) # Have Kennelish parse the data. body = Kennelish.parse(data, user_data) @@ -374,9 +372,9 @@ async def forms( "form.html", { "request": request, - "icon": payload["pfp"], - "name": payload["name"], - "id": payload["id"], + "icon": user_jwt["pfp"], + "name": user_jwt["name"], + "id": user_jwt["id"], "body": body, }, ) diff --git a/requirements.txt b/requirements.txt index 9dd4a7a..1957628 100644 --- a/requirements.txt +++ b/requirements.txt @@ -74,3 +74,7 @@ uvloop==0.19.0 virtualenv==20.25.1 watchfiles==0.21.0 websockets==12.0 +jinja2 +commonmark +redis +pydantic_settings diff --git a/routes/admin.py b/routes/admin.py index 3952a28..5d6bf7a 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -2,7 +2,7 @@ import boto3 from boto3.dynamodb.conditions import Attr -from fastapi import APIRouter, Body, Cookie, Request, Response +from fastapi import APIRouter, Body, Cookie, Request, Response, Depends from fastapi.encoders import jsonable_encoder from fastapi.templating import Jinja2Templates from jose import jwt @@ -13,15 +13,18 @@ from util.discord import Discord from util.email import Email from util.errors import Errors -from util.options import Options +from util.options import Settings -options = Options.fetch() templates = Jinja2Templates(directory="templates") router = APIRouter(prefix="/admin", tags=["Admin"], responses=Errors.basic_http()) +@router.get("/test") +async def test(request: Request): + return {"msg": Settings().jwt.secret.get_secret_value()} + @router.get("/") @Authentication.admin async def admin(request: Request, token: Optional[str] = Cookie(None)): @@ -30,8 +33,8 @@ async def admin(request: Request, token: Optional[str] = Cookie(None)): """ payload = jwt.decode( token, - options.get("jwt").get("secret"), - algorithms=options.get("jwt").get("algorithm"), + Settings().jwt.secret.get_secret_value(), + algorithms=Settings().jwt.algorithm, ) return templates.TemplateResponse( "admin_searcher.html", @@ -66,7 +69,7 @@ async def get_infra( # Get user data dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) user_data = table.get_item(Key={"id": member_id}).get("Item", None) @@ -75,16 +78,16 @@ async def get_infra( We are happy to grant you Hack@UCF Private Cloud access! -These credentials can be used to the Hack@UCF Private Cloud. This can be accessed at {options.get('infra', {}).get('horizon')} while on the CyberLab WiFi. +These credentials can be used to the Hack@UCF Private Cloud. This can be accessed at {Settings().infra.horizon} while on the CyberLab WiFi. ``` Username: {creds.get('username', 'Not Set')} -Password: {creds.get('password', f"Please visit https://{options.get('http', {}).get('domain')}/profile and under Danger Zone, reset your Infra creds.")} +Password: {creds.get('password', f"Please visit https://{Settings().http.domain}/profile and under Danger Zone, reset your Infra creds.")} ``` By using the Hack@UCF Infrastructure, you agree to the following EULA located at https://help.hackucf.org/misc/eula -The password for the `Cyberlab` WiFi is currently `{options.get('infra', {}).get('wifi')}`, but this is subject to change (and we'll let you know when that happens). +The password for the `Cyberlab` WiFi is currently `{Settings().infra.wifi}`, but this is subject to change (and we'll let you know when that happens). Happy Hacking, - Hack@UCF Bot @@ -112,7 +115,7 @@ async def get_refresh( Approve.approve_member(member_id) dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) data = table.get_item(Key={"id": member_id}).get("Item", None) if not data: @@ -135,7 +138,7 @@ async def admin_get_single( return {"data": {}, "error": "Missing ?member_id"} dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) data = table.get_item(Key={"id": member_id}).get("Item", None) if not data: @@ -159,7 +162,7 @@ async def admin_get_snowflake( return {"data": {}, "error": "Missing ?discord_id"} dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) data = table.scan(FilterExpression=Attr("discord_id").eq(str(discord_id))).get( "Items" ) @@ -193,7 +196,7 @@ async def admin_post_discord_message( return {"data": {}, "error": "Missing ?member_id"} dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) data = table.get_item(Key={"id": member_id}).get("Item", None) if not data: @@ -222,7 +225,7 @@ async def admin_edit( member_id = input_data.id dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) old_data = table.get_item(Key={"id": member_id}).get("Item", None) if not old_data: @@ -252,7 +255,7 @@ async def admin_list(request: Request, token: Optional[str] = Cookie(None)): API endpoint that dumps all users as JSON. """ dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) data = table.scan().get("Items", None) return {"data": data} @@ -264,7 +267,7 @@ async def admin_list_csv(request: Request, token: Optional[str] = Cookie(None)): API endpoint that dumps all users as CSV. """ dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) data = table.scan().get("Items", None) output = "Membership ID, First Name, Last Name, NID, Is Returning, Gender, Major, Class Standing, Shirt Size, Discord Username, Experience, Cyber Interests, Event Interest, Is C3 Interest, Comments, Ethics Form Timestamp, Minecraft, Infra Email\n" diff --git a/routes/api.py b/routes/api.py index 59fdfa5..450042b 100644 --- a/routes/api.py +++ b/routes/api.py @@ -12,9 +12,9 @@ from util.authentication import Authentication from util.errors import Errors from util.kennelish import Kennelish, Transformer -from util.options import Options +from util.options import Settings + -options = Options.fetch() router = APIRouter(prefix="/api", tags=["API"], responses=Errors.basic_http()) @@ -47,7 +47,7 @@ async def get_root(): @router.get("/form/{num}") async def get_form(num: str): - return Options.get_form_body(num) + return Forms.get_form_body(num)(num) """ @@ -65,10 +65,10 @@ async def get_form_html( ): # AWS dependencies dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) # Get form object - data = Options.get_form_body(num) + data = Forms.get_form_body(num)(num) # Get data from DynamoDB user_data = table.get_item(Key={"id": user_jwt.get("id")}).get("Item", None) @@ -93,7 +93,7 @@ async def post_form( num: str = 1, ): # Get Kennelish data - kennelish_data = Options.get_form_body(num) + kennelish_data = Forms.get_form_body(num)(num) model = Transformer.kennelish_to_pydantic(kennelish_data) # Parse and Validate inputs @@ -142,7 +142,7 @@ async def post_form( # AWS dependencies dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) # Push data back to DynamoDB try: diff --git a/routes/infra.py b/routes/infra.py index b8c23cf..efe7635 100644 --- a/routes/infra.py +++ b/routes/infra.py @@ -19,11 +19,11 @@ from util.email import Email from util.errors import Errors from util.limiter import RateLimiter -from util.options import Options +from util.options import Settings + logger = logging.getLogger(__name__) -options = Options.fetch() templates = Jinja2Templates(directory="templates") @@ -32,9 +32,7 @@ tf = Terraform(working_dir="./") rate_limiter = RateLimiter( - options.get("redis").get("host"), - options.get("redis").get("port"), - options.get("redis").get("db"), + Settings().redis.host, Settings().redis.port, Settings().redis.db ) rate_limiter.get_redis() @@ -64,8 +62,8 @@ async def create_resource(project, callback_discord_id=None): logger.info(f"Creating resources for {proj_name}...") tf_vars = { - "username": options.get("infra", {}).get("ad", {}).get("username"), - "password": options.get("infra", {}).get("ad", {}).get("password"), + "application_credential_id": Settings().infra.application_credential_id, + "application_credential_secret": Settings().infra.application_credential_secret.get_secret_value(), "tenant_name": proj_name, "gbmname": shitty_database.get("gbmName"), "imageid": shitty_database.get("imageId"), @@ -91,7 +89,7 @@ async def create_resource(project, callback_discord_id=None): if callback_discord_id: resource_create_msg = f"""Hello! -Your requested virtual machine has been created! You can now view it at {options.get('infra', {}).get('horizon')}. +Your requested virtual machine has been created! You can now view it at {Settings().infra.horizon}. Enjoy, - Hack@UCF Bot @@ -226,7 +224,7 @@ async def get_teardown(request: Request, token: Optional[str] = Cookie(None)): """ -API endpoint to SET the one-click deploy settings. +API endpoint to SET the one-click deploy Settings(). """ @@ -241,7 +239,7 @@ async def get_options( """ -API endpoint to SET the one-click deploy settings. +API endpoint to SET the one-click deploy Settings(). """ @@ -290,7 +288,7 @@ async def get_infra( # Get user data dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) user_data = table.get_item(Key={"id": member_id}).get("Item", None) @@ -299,14 +297,14 @@ async def get_infra( You have requested to reset your Hack@UCF Infrastructure credentials. This change comes with new credentials. -A reminder that you can use these credentials at {options.get('infra', {}).get('horizon')} while on the CyberLab WiFi. +A reminder that you can use these credentials at {Settings().infra.horizon} while on the CyberLab WiFi. ``` Username: {creds.get('username', 'Not Set')} -Password: {creds.get('password', f"Please visit https://{options.get('http', {}).get('domain')}/profile and under Danger Zone, reset your Infra creds.")} +Password: {creds.get('password', f"Please visit https://{Settings().http.domain}/profile and under Danger Zone, reset your Infra creds.")} ``` -The password for the `Cyberlab` WiFi is currently `{options.get('infra', {}).get('wifi')}`, but this is subject to change (and we'll let you know when that happens). +The password for the `Cyberlab` WiFi is currently `{Settings().infra.wifi}`, but this is subject to change (and we'll let you know when that happens). By using the Hack@UCF Infrastructure, you agree to the following EULA located at https://help.hackucf.org/misc/eula diff --git a/routes/stripe.py b/routes/stripe.py index 16519a9..10551c7 100644 --- a/routes/stripe.py +++ b/routes/stripe.py @@ -11,9 +11,9 @@ from util.approve import Approve from util.authentication import Authentication from util.errors import Errors -from util.options import Options +from util.options import Settings + -options = Options.fetch() templates = Jinja2Templates(directory="templates") @@ -22,7 +22,7 @@ router = APIRouter(prefix="/pay", tags=["API"], responses=Errors.basic_http()) # Set Stripe API key. -stripe.api_key = options.get("stripe").get("api_key") +stripe.api_key = Settings().stripe.api_key.get_secret_value() """ @@ -39,7 +39,7 @@ async def get_root( ): # AWS dependencies dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) # Get data from DynamoDB user_data = table.get_item(Key={"id": user_jwt.get("id")}).get("Item", None) @@ -70,7 +70,7 @@ async def create_checkout_session( ): # AWS dependencies dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) # Get data from DynamoDB user_data = table.get_item(Key={"id": user_jwt.get("id")}).get("Item", None) @@ -81,14 +81,14 @@ async def create_checkout_session( line_items=[ { # Provide the exact Price ID (for example, pr_1234) of the product you want to sell - "price": options.get("stripe").get("price_id"), + "price": Settings().stripe.price_id, "quantity": 1, }, ], customer_email=stripe_email, mode="payment", - success_url=options.get("stripe").get("url").get("success"), - cancel_url=options.get("stripe").get("url").get("failure"), + success_url=Settings().stripe.url_success, + cancel_url=Settings().stripe.url_failure, ) except Exception as e: return str(e) @@ -101,7 +101,7 @@ async def webhook(request: Request): payload = await request.body() sig_header = request.headers.get("stripe-signature") event = None - endpoint_secret = options.get("stripe").get("webhook_secret") + endpoint_secret = Settings().stripe.webhook_secret.get_secret_value() try: event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret) @@ -136,7 +136,7 @@ def pay_dues(session): # AWS dependencies dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) # Get data from DynamoDB response = table.scan(FilterExpression=Attr("email").eq(customer_email)).get( diff --git a/routes/wallet.py b/routes/wallet.py index 05d32df..f3649fa 100644 --- a/routes/wallet.py +++ b/routes/wallet.py @@ -12,9 +12,9 @@ from models.user import PublicContact from util.authentication import Authentication from util.errors import Errors -from util.options import Options +from util.options import Settings + -options = Options.fetch() router = APIRouter( prefix="/wallet", tags=["API", "MobileWallet"], responses=Errors.basic_http() @@ -240,7 +240,7 @@ async def aapl_gen( user_jwt: Optional[object] = {}, ): dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) # Get data from DynamoDB user_data = table.get_item(Key={"id": user_jwt.get("id")}).get("Item", None) diff --git a/util/approve.py b/util/approve.py index f1cac08..2636944 100644 --- a/util/approve.py +++ b/util/approve.py @@ -8,12 +8,12 @@ from util.discord import Discord from util.email import Email from util.horsepass import HorsePass -from util.options import Options +from util.options import Settings logger = logging.getLogger() -options = Options.fetch() -tf = Terraform(working_dir=options.get("infra", {}).get("tf_directory", "./")) + +tf = Terraform(working_dir=Settings().infra.tf_directory) """ This function will ensure a member meets all requirements to be a member, and if so, creates an @@ -43,7 +43,7 @@ def provision_infra(member_id, user_data=None): try: dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) if not user_data: user_data = table.get_item(Key={"id": member_id}).get("Item", None) @@ -126,7 +126,7 @@ def provision_infra(member_id, user_data=None): def approve_member(member_id): logger.info(f"Re-running approval for {member_id}") dynamodb = boto3.resource("dynamodb") - table = dynamodb.Table(options.get("aws").get("dynamodb").get("table")) + table = dynamodb.Table(Settings().aws.table) user_data = table.get_item(Key={"id": member_id}).get("Item", None) @@ -164,22 +164,22 @@ def approve_member(member_id): # Assign the Dues-Paying Member role Discord.assign_role( - discord_id, options.get("discord", {}).get("member_role") + discord_id, Settings().discord.member_role ) # Send Discord message saying they are a member welcome_msg = f"""Hello {user_data.get('first_name')}, and welcome to Hack@UCF! -This message is to confirm that your membership has processed successfully. You can access and edit your membership ID at https://{options.get('http', {}).get('domain')}/profile. +This message is to confirm that your membership has processed successfully. You can access and edit your membership ID at https://{Settings().http.domain}/profile. -These credentials can be used to the Hack@UCF Private Cloud, one of our many benefits of paying dues. This can be accessed at {options.get('infra', {}).get('horizon')} while on the CyberLab WiFi. +These credentials can be used to the Hack@UCF Private Cloud, one of our many benefits of paying dues. This can be accessed at {Settings().infra.horizon} while on the CyberLab WiFi. ```yaml Username: {creds.get('username', 'Not Set')} -Password: {creds.get('password', f"Please visit https://{options.get('http', {}).get('domain')}/profile and under Danger Zone, reset your Infra creds.")} +Password: {creds.get('password', f"Please visit https://{Settings().http.domain}/profile and under Danger Zone, reset your Infra creds.")} ``` -The password for the `Cyberlab` WiFi is currently `{options.get('infra', {}).get('wifi')}`, but this is subject to change (and we'll let you know when that happens). +The password for the `Cyberlab` WiFi is currently `{Settings().infra.wifi}`, but this is subject to change (and we'll let you know when that happens). By using the Hack@UCF Infrastructure, you agree to the following EULA located at https://help.hackucf.org/misc/eula @@ -207,7 +207,7 @@ def approve_member(member_id): - Signed Ethics Form: {'✅' if user_data.get('ethics_form', {}).get('signtime', 0) != 0 else '❌'} - Paid $10 dues: ✅ -Please complete all of these to become a full member. Once you do, visit https://{options.get('http', {}).get('domain')}/profile to re-run this check. +Please complete all of these to become a full member. Once you do, visit https://{Settings().http.domain}/profile to re-run this check. If you think you have completed all of these, please reach out to an Exec on the Hack@UCF Discord. diff --git a/util/authentication.py b/util/authentication.py index 3de44f2..090b1ba 100644 --- a/util/authentication.py +++ b/util/authentication.py @@ -8,9 +8,9 @@ # Import options and errors from util.errors import Errors -from util.options import Options +from util.options import Settings + -options = Options.fetch() class Authentication: @@ -28,13 +28,13 @@ async def wrapper(request: Request, token: Optional[str], *args, **kwargs): ) try: - payload = jwt.decode( + user_jwt = jwt.decode( token, - options.get("jwt").get("secret"), - algorithms=options.get("jwt").get("algorithm"), + Settings().jwt.secret.get_secret_value(), + algorithms=Settings().jwt.algorithm, ) - is_admin: bool = payload.get("sudo", False) - creation_date: float = payload.get("issued", -1) + is_admin: bool = user_jwt.get("sudo", False) + creation_date: float = user_jwt.get("issued", -1) except Exception: tr = Errors.generate( request, @@ -52,9 +52,7 @@ async def wrapper(request: Request, token: Optional[str], *args, **kwargs): essay="If you think this is an error, please try logging in again.", ) - if time.time() > creation_date + options.get("jwt").get("lifetime").get( - "sudo" - ): + if time.time() > creation_date + Settings().jwt.lifetime_sudo: return Errors.generate( request, 403, @@ -72,7 +70,7 @@ def member(func): async def wrapper_member( request: Request, token: Optional[str], - payload: Optional[object], + user_jwt: Optional[object], *args, **kwargs ): @@ -84,12 +82,12 @@ async def wrapper_member( ) try: - payload = jwt.decode( + user_jwt = jwt.decode( token, - options.get("jwt").get("secret"), - algorithms=options.get("jwt").get("algorithm"), + Settings().jwt.secret.get_secret_value(), + algorithms=Settings().jwt.algorithm, ) - creation_date: float = payload.get("issued", -1) + creation_date: float = user_jwt.get("issued", -1) except Exception: tr = Errors.generate( request, @@ -99,9 +97,7 @@ async def wrapper_member( tr.delete_cookie(key="token") return tr - if time.time() > creation_date + options.get("jwt").get("lifetime").get( - "user" - ): + if time.time() > creation_date + Settings().jwt.lifetime_user: return Errors.generate( request, 403, @@ -109,6 +105,6 @@ async def wrapper_member( essay="Sessions last for about fifteen weeks. You need to re-log-in between semesters.", ) - return await func(request, token, payload, *args, **kwargs) + return await func(request, token, user_jwt, *args, **kwargs) return wrapper_member diff --git a/util/discord.py b/util/discord.py index 67d1609..bec58fb 100644 --- a/util/discord.py +++ b/util/discord.py @@ -2,12 +2,12 @@ import requests -from util.options import Options +from util.options import Settings + -options = Options.fetch() headers = { - "Authorization": f"Bot {options.get('discord', {}).get('bot_token')}", + "Authorization": f"Bot {Settings().discord.bot_token.get_secret_value()}", "Content-Type": "application/json", "X-Audit-Log-Reason": "Hack@UCF OnboardLite Bot", } @@ -25,7 +25,7 @@ def assign_role(discord_id, role_id): discord_id = str(discord_id) req = requests.put( - f"https://discord.com/api/guilds/{options.get('discord', {}).get('guild_id')}/members/{discord_id}/roles/{role_id}", + f"https://discord.com/api/guilds/{Settings().discord.guild_id}/members/{discord_id}/roles/{role_id}", headers=headers, ) diff --git a/util/email.py b/util/email.py index 43705be..c7e7ee4 100644 --- a/util/email.py +++ b/util/email.py @@ -4,19 +4,12 @@ import commonmark -from util.options import Options +from util.options import Settings -options = Options.fetch() - -# from util.options import Options -options = Options.fetch() - -email = options.get("email", {}).get("email", {}) - -email = options.get("email", {}).get("email", {}) -password = options.get("email", {}).get("password", {}) -smtp_host = options.get("email", {}).get("smtp_server", {}) +email = Settings().email.email +password = Settings().email.password.get_secret_value() +smtp_host = Settings().email.smtp_server class Email: diff --git a/util/forms.py b/util/forms.py new file mode 100644 index 0000000..54db84a --- /dev/null +++ b/util/forms.py @@ -0,0 +1,10 @@ +import json +import os + +class Forms: + def get_form_body(file="1"): + try: + form_file = os.path.join(os.getcwd(), "forms", f"{file}.json") + return json.load(open(form_file, "r")) + except FileNotFoundError: + return {} \ No newline at end of file diff --git a/util/options.py b/util/options.py index 48f5b12..6a347e1 100644 --- a/util/options.py +++ b/util/options.py @@ -1,31 +1,123 @@ -import json import os - +from pydantic import BaseModel, SecretStr, constr +from pydantic_settings import BaseSettings import yaml +import json +import subprocess +from functools import lru_cache + +#TODO +# 1. Add constr to strings to enforce length and format +# 2. Test Bitwarden overwrites + +yaml_settings = dict() + +here = os.path.abspath(os.path.dirname(__file__)) +with open(os.path.join(here, "../config/options.yml")) as f: + yaml_settings.update(yaml.load(f, Loader=yaml.FullLoader)) +def parse_json_to_dict(json_string): + data = json.loads(json_string) + return {item['key']: item['value'] for item in data} + +try: + command = f"bws secret list {yaml_settings['bws']['project_id']}" + bitwarden_raw = subprocess.check_output(command, shell=True, text=True) +except Exception as e: + print(e) +bitwarden_settings = parse_json_to_dict(bitwarden_raw) + +bitwarden_mapping = { + 'discord_bot_token': ('discord', 'bot_token'), + 'discord_client_id': ('discord', 'client_id'), + 'discord_secret': ('discord', 'secret'), + 'stripe_api_key': ('stripe', 'api_key'), + 'stripe_webhook_secret': ('stripe', 'webhook_secret'), + 'stripe_price_id': ('stripe', 'price_id'), + 'email_password': ('email', 'password'), + 'jwt_secret': ('jwt', 'secret'), + 'infra_wifi': ('infra', 'wifi'), + 'infra_application_credential_id': ('infra', 'application_credential_id'), + 'infra_configuration_credential_secret': ('infra', 'application_credential_secret') +} +bitwarden_mapped = {} +for bw_key, nested_keys in bitwarden_mapping.items(): + if bw_key in bitwarden_settings: + top_key, nested_key = nested_keys + if top_key not in bitwarden_mapped: + bitwarden_mapped[top_key] = {} + bitwarden_mapped[top_key][nested_key] = bitwarden_settings[bw_key] + +for top_key, nested_dict in bitwarden_mapped.items(): + if top_key in yaml_settings: + for nested_key, value in nested_dict.items(): + yaml_settings[top_key][nested_key] = value + +class DiscordConfig(BaseModel): + bot_token: SecretStr + client_id: int + guild_id: int + member_role: int + redirect_base: str + scope: str + secret: SecretStr + +discord_config = DiscordConfig(**yaml_settings['discord']) +class StripeConfig(BaseModel): + api_key: SecretStr + webhook_secret: SecretStr + price_id: str + url_success: str + url_failure: str +stripe_config = StripeConfig(**yaml_settings['stripe']) +class EmailConfig(BaseModel): + smtp_server: str + email: str + password: SecretStr +email_config = EmailConfig(**yaml_settings['email']) -class Options: - def __init__(self): - super(Options, self).__init__ +class JwtConfig(BaseModel): + secret: SecretStr + algorithm: str + lifetime_user: int + lifetime_sudo: int +jwt_config = JwtConfig(**yaml_settings['jwt']) - def fetch(path="config/options.yml"): - # Get file path - full_path = os.path.join(os.getcwd(), path) +class DynamodbConfig(BaseModel): + table: str +dynamodb_config = DynamodbConfig(**yaml_settings['aws']['dynamodb']) - # Load options. - with open(full_path, "r") as file: - options = yaml.safe_load(file) +class InfraConfig(BaseModel): + wifi: str + horizon: str + application_credential_id: str + application_credential_secret: SecretStr + tf_directory: str +infra_config = InfraConfig(**yaml_settings['infra']) - return options +class RedisConfig(BaseModel): + host: str + port: int + db: int +redis_config = RedisConfig(**yaml_settings['redis']) - def get(self, arg=None): - options = self.fetch() +class HttpConfig(BaseModel): + domain: str +http_config = HttpConfig(**yaml_settings['http']) - return options.get(arg, None) +class SingletonBaseSettingsMeta(type(BaseSettings), type): + _instances = {} - def get_form_body(file="1"): - try: - form_file = os.path.join(os.getcwd(), "forms", f"{file}.json") - return json.load(open(form_file, "r")) - except FileNotFoundError: - return {} + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + return cls._instances[cls] +class Settings(BaseSettings, metaclass=SingletonBaseSettingsMeta): + discord: DiscordConfig = discord_config + stripe: StripeConfig = stripe_config + email: EmailConfig = email_config + jwt: JwtConfig = jwt_config + aws: DynamodbConfig = dynamodb_config + infra: InfraConfig = infra_config + redis: RedisConfig = redis_config + http: HttpConfig = http_config From 5c036391e20bf47adbc4668b43d92e54a1122785 Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Mon, 18 Mar 2024 13:39:37 -0400 Subject: [PATCH 2/9] Reworked Options moved bitwarden to module fixed bugs --- config.yml | 8 ++- index.py | 10 +++- routes/admin.py | 4 -- util/options.py | 152 +++++++++++++++++++++++++++++++++--------------- 4 files changed, 120 insertions(+), 54 deletions(-) diff --git a/config.yml b/config.yml index aeebfbc..055df64 100644 --- a/config.yml +++ b/config.yml @@ -1,4 +1,7 @@ # Options for Onboard. +bws: + project_id: + enable: jwt: secret: algorithm: "HS256" @@ -11,9 +14,8 @@ http: infra: wifi: "" horizon: "https://horizon.hackucf.org" - ad: - application_credential_id: - application_credential_secret: + application_credential_id: + application_credential_secret: tf_directory: "./" discord: diff --git a/index.py b/index.py index 9e6919c..1df18d4 100644 --- a/index.py +++ b/index.py @@ -358,8 +358,16 @@ async def forms( if num == "1": return RedirectResponse("/join/", status_code=status.HTTP_302_FOUND) + try: + data = Forms.get_form_body(num) + except Exception: + return Errors.generate( + request, + 404, + "Form not found", + essay="This form does not exist.", + ) - data = Forms.get_form_body(num) # Get data from DynamoDB user_data = table.get_item(Key={"id": user_jwt.get("id")}).get("Item", None) diff --git a/routes/admin.py b/routes/admin.py index 5d6bf7a..6780536 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -21,10 +21,6 @@ router = APIRouter(prefix="/admin", tags=["Admin"], responses=Errors.basic_http()) -@router.get("/test") -async def test(request: Request): - return {"msg": Settings().jwt.secret.get_secret_value()} - @router.get("/") @Authentication.admin async def admin(request: Request, token: Optional[str] = Cookie(None)): diff --git a/util/options.py b/util/options.py index 6a347e1..2217064 100644 --- a/util/options.py +++ b/util/options.py @@ -6,53 +6,76 @@ import subprocess from functools import lru_cache -#TODO -# 1. Add constr to strings to enforce length and format -# 2. Test Bitwarden overwrites - -yaml_settings = dict() +def BitwardenConfig(settings_dict: dict): + ''' + Takes a dict of settings loaded from yaml and adds the secrets from bitwarden to the settings dict. + The bitwarden secrets are mapped to the settings dict using the bitwarden_mapping dict. + The secrets are sourced based on a project id in the settings dict. + ''' + try: + command = f"bws secret list {settings_dict['bws']['project_id']}" + bitwarden_raw = subprocess.check_output(command, shell=True, text=True) + except Exception as e: + print(e) + bitwarden_settings = parse_json_to_dict(bitwarden_raw) + + bitwarden_mapping = { + 'discord_bot_token': ('discord', 'bot_token'), + 'discord_client_id': ('discord', 'client_id'), + 'discord_secret': ('discord', 'secret'), + 'stripe_api_key': ('stripe', 'api_key'), + 'stripe_webhook_secret': ('stripe', 'webhook_secret'), + 'stripe_price_id': ('stripe', 'price_id'), + 'email_password': ('email', 'password'), + 'jwt_secret': ('jwt', 'secret'), + 'infra_wifi': ('infra', 'wifi'), + 'infra_application_credential_id': ('infra', 'application_credential_id'), + 'infra_configuration_credential_secret': ('infra', 'application_credential_secret') + } + + bitwarden_mapped = {} + for bw_key, nested_keys in bitwarden_mapping.items(): + if bw_key in bitwarden_settings: + top_key, nested_key = nested_keys + if top_key not in bitwarden_mapped: + bitwarden_mapped[top_key] = {} + bitwarden_mapped[top_key][nested_key] = bitwarden_settings[bw_key] + + for top_key, nested_dict in bitwarden_mapped.items(): + if top_key in settings_dict: + for nested_key, value in nested_dict.items(): + settings_dict[top_key][nested_key] = value + return settings_dict + +settings_dict = dict() + +# Reads config from ../config/options.yml here = os.path.abspath(os.path.dirname(__file__)) with open(os.path.join(here, "../config/options.yml")) as f: - yaml_settings.update(yaml.load(f, Loader=yaml.FullLoader)) + settings_dict.update(yaml.load(f, Loader=yaml.FullLoader)) def parse_json_to_dict(json_string): data = json.loads(json_string) return {item['key']: item['value'] for item in data} -try: - command = f"bws secret list {yaml_settings['bws']['project_id']}" - bitwarden_raw = subprocess.check_output(command, shell=True, text=True) -except Exception as e: - print(e) -bitwarden_settings = parse_json_to_dict(bitwarden_raw) - -bitwarden_mapping = { - 'discord_bot_token': ('discord', 'bot_token'), - 'discord_client_id': ('discord', 'client_id'), - 'discord_secret': ('discord', 'secret'), - 'stripe_api_key': ('stripe', 'api_key'), - 'stripe_webhook_secret': ('stripe', 'webhook_secret'), - 'stripe_price_id': ('stripe', 'price_id'), - 'email_password': ('email', 'password'), - 'jwt_secret': ('jwt', 'secret'), - 'infra_wifi': ('infra', 'wifi'), - 'infra_application_credential_id': ('infra', 'application_credential_id'), - 'infra_configuration_credential_secret': ('infra', 'application_credential_secret') -} -bitwarden_mapped = {} -for bw_key, nested_keys in bitwarden_mapping.items(): - if bw_key in bitwarden_settings: - top_key, nested_key = nested_keys - if top_key not in bitwarden_mapped: - bitwarden_mapped[top_key] = {} - bitwarden_mapped[top_key][nested_key] = bitwarden_settings[bw_key] - -for top_key, nested_dict in bitwarden_mapped.items(): - if top_key in yaml_settings: - for nested_key, value in nested_dict.items(): - yaml_settings[top_key][nested_key] = value +# If bitwarden is enabled, add secrets to settings_dict +if settings_dict.get('bws').get('enable'): + settings_dict = BitwardenConfig(settings_dict) + class DiscordConfig(BaseModel): + """ + Represents the configuration settings for Discord integration. + + Attributes: + bot_token (SecretStr): The secret token for the Discord bot. + client_id (int): The client ID for the Discord application. + guild_id (int): The ID of the HackUCF discord server. + member_role (int): The ID of the role assigned to members. + redirect_base (str): The base URL for redirecting after authentication. + scope (str): The scope of permissions required for the Discord integration. + secret (SecretStr): The secret key for the Discord oauth. + """ bot_token: SecretStr client_id: int guild_id: int @@ -61,49 +84,86 @@ class DiscordConfig(BaseModel): scope: str secret: SecretStr -discord_config = DiscordConfig(**yaml_settings['discord']) +discord_config = DiscordConfig(**settings_dict['discord']) class StripeConfig(BaseModel): + """ + Configuration class for Stripe integration. + + Attributes: + api_key (SecretStr): The API key for Stripe. + webhook_secret (SecretStr): The webhook secret for Stripe. + price_id (str): The ID of the price for the product. + url_success (str): The URL to redirect to on successful payment. + url_failure (str): The URL to redirect to on failed payment. + """ api_key: SecretStr webhook_secret: SecretStr price_id: str url_success: str url_failure: str -stripe_config = StripeConfig(**yaml_settings['stripe']) +stripe_config = StripeConfig(**settings_dict['stripe']) class EmailConfig(BaseModel): + """ + Represents the configuration for an email. + + Attributes: + smtp_server (str): The SMTP server address. + email (str): The email address to send from also used as the login username. + password (SecretStr): The password for the email account. + """ smtp_server: str email: str password: SecretStr -email_config = EmailConfig(**yaml_settings['email']) +email_config = EmailConfig(**settings_dict['email']) class JwtConfig(BaseModel): + """ + Configuration class for JWT (JSON Web Token) settings. + + Attributes: + secret (SecretStr): The secret key used for signing and verifying JWTs. + algorithm (str): The algorithm used for JWT encryption. + lifetime_user (int): The lifetime (in seconds) of a user JWT. + lifetime_sudo (int): The lifetime (in seconds) of a sudo JWT. + """ secret: SecretStr algorithm: str lifetime_user: int lifetime_sudo: int -jwt_config = JwtConfig(**yaml_settings['jwt']) +jwt_config = JwtConfig(**settings_dict['jwt']) class DynamodbConfig(BaseModel): table: str -dynamodb_config = DynamodbConfig(**yaml_settings['aws']['dynamodb']) +dynamodb_config = DynamodbConfig(**settings_dict['aws']['dynamodb']) class InfraConfig(BaseModel): + """ + Represents the infrastructure configuration. + + Attributes: + wifi (str): The WiFi password used in welcome messages. + horizon (str): The url of the openstack horizon interface (also used to derive the keystone endpoint). + application_credential_id (str): The application credential ID used to provision users and projects. + application_credential_secret (SecretStr): The application credential secret used to provision users and projects. + tf_directory (str): The Terraform directory. + """ wifi: str horizon: str application_credential_id: str application_credential_secret: SecretStr tf_directory: str -infra_config = InfraConfig(**yaml_settings['infra']) +infra_config = InfraConfig(**settings_dict['infra']) class RedisConfig(BaseModel): host: str port: int db: int -redis_config = RedisConfig(**yaml_settings['redis']) +redis_config = RedisConfig(**settings_dict['redis']) class HttpConfig(BaseModel): domain: str -http_config = HttpConfig(**yaml_settings['http']) +http_config = HttpConfig(**settings_dict['http']) class SingletonBaseSettingsMeta(type(BaseSettings), type): _instances = {} From 1023c853548a4c2205225516a7e049eb8bf7515d Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Mon, 18 Mar 2024 13:40:14 -0400 Subject: [PATCH 3/9] Fixed exception handling in authentication modules Fixed Forms to return proper execptions --- routes/api.py | 20 +++++++++++++++----- util/authentication.py | 41 +++++++++++++++++++++++------------------ util/forms.py | 2 +- 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/routes/api.py b/routes/api.py index 450042b..91262ed 100644 --- a/routes/api.py +++ b/routes/api.py @@ -3,7 +3,7 @@ import boto3 from botocore.exceptions import ClientError -from fastapi import APIRouter, Cookie, Request +from fastapi import APIRouter, Cookie, Request, HTTPException from fastapi.responses import HTMLResponse from pydantic import error_wrappers @@ -13,6 +13,7 @@ from util.errors import Errors from util.kennelish import Kennelish, Transformer from util.options import Settings +from util.forms import Forms @@ -47,7 +48,10 @@ async def get_root(): @router.get("/form/{num}") async def get_form(num: str): - return Forms.get_form_body(num)(num) + try: + return Forms.get_form_body(num) + except FileNotFoundError: + return HTTPException(status_code=404, detail="Form not found") """ @@ -68,8 +72,10 @@ async def get_form_html( table = dynamodb.Table(Settings().aws.table) # Get form object - data = Forms.get_form_body(num)(num) - + try: + data = Forms.get_form_body(num) + except FileNotFoundError: + return HTTPException(status_code=404, detail="Form not found") # Get data from DynamoDB user_data = table.get_item(Key={"id": user_jwt.get("id")}).get("Item", None) @@ -93,7 +99,11 @@ async def post_form( num: str = 1, ): # Get Kennelish data - kennelish_data = Forms.get_form_body(num)(num) + try: + kennelish_data = Forms.get_form_body(num) + except FileNotFoundError: + return HTTPException(status_code=404, detail="Form not found") + model = Transformer.kennelish_to_pydantic(kennelish_data) # Parse and Validate inputs diff --git a/util/authentication.py b/util/authentication.py index 090b1ba..f59ea06 100644 --- a/util/authentication.py +++ b/util/authentication.py @@ -35,14 +35,17 @@ async def wrapper(request: Request, token: Optional[str], *args, **kwargs): ) is_admin: bool = user_jwt.get("sudo", False) creation_date: float = user_jwt.get("issued", -1) - except Exception: - tr = Errors.generate( - request, - 403, - "Invalid token provided. Please log in again (refresh the page) and try again.", - ) - tr.delete_cookie(key="token") - return tr + except Exception as e: + if isinstance(e, jwt.JWTError) or isinstance(e, jwt.JWTClaimsError): + tr = Errors.generate( + request, + 403, + "Invalid token provided. Please log in again (refresh the page) and try again.", + ) + tr.delete_cookie(key="token") + return tr + else: + raise # Re-raise exceptions that are not related to token validation if not is_admin: return Errors.generate( @@ -88,15 +91,18 @@ async def wrapper_member( algorithms=Settings().jwt.algorithm, ) creation_date: float = user_jwt.get("issued", -1) - except Exception: - tr = Errors.generate( - request, - 403, - "Invalid token provided. Please log in again (refresh the page) and try again.", - ) - tr.delete_cookie(key="token") - return tr - + except Exception as e: + if isinstance(e, jwt.JWTError) or isinstance(e, jwt.JWTClaimsError): + tr = Errors.generate( + request, + 403, + "Invalid token provided. Please log in again (refresh the page) and try again.", + ) + tr.delete_cookie(key="token") + return tr + else: + raise # Re-raise exceptions that are not related to token validation + if time.time() > creation_date + Settings().jwt.lifetime_user: return Errors.generate( request, @@ -104,7 +110,6 @@ async def wrapper_member( "Session expired.", essay="Sessions last for about fifteen weeks. You need to re-log-in between semesters.", ) - return await func(request, token, user_jwt, *args, **kwargs) return wrapper_member diff --git a/util/forms.py b/util/forms.py index 54db84a..f1f0296 100644 --- a/util/forms.py +++ b/util/forms.py @@ -7,4 +7,4 @@ def get_form_body(file="1"): form_file = os.path.join(os.getcwd(), "forms", f"{file}.json") return json.load(open(form_file, "r")) except FileNotFoundError: - return {} \ No newline at end of file + raise \ No newline at end of file From 1c578e6ae15894e985ac85ef94271cc4ec4fa231 Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Mon, 18 Mar 2024 13:43:04 -0400 Subject: [PATCH 4/9] removed unnecessary dependencies --- routes/admin.py | 2 +- util/options.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/routes/admin.py b/routes/admin.py index 6780536..7996a77 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -2,7 +2,7 @@ import boto3 from boto3.dynamodb.conditions import Attr -from fastapi import APIRouter, Body, Cookie, Request, Response, Depends +from fastapi import APIRouter, Body, Cookie, Request, Response from fastapi.encoders import jsonable_encoder from fastapi.templating import Jinja2Templates from jose import jwt diff --git a/util/options.py b/util/options.py index 2217064..fe8d6c3 100644 --- a/util/options.py +++ b/util/options.py @@ -4,7 +4,6 @@ import yaml import json import subprocess -from functools import lru_cache def BitwardenConfig(settings_dict: dict): @@ -122,12 +121,12 @@ class JwtConfig(BaseModel): Configuration class for JWT (JSON Web Token) settings. Attributes: - secret (SecretStr): The secret key used for signing and verifying JWTs. + secret (SecretStr): The secret key used for signing and verifying JWTs.(min_length=32) algorithm (str): The algorithm used for JWT encryption. lifetime_user (int): The lifetime (in seconds) of a user JWT. lifetime_sudo (int): The lifetime (in seconds) of a sudo JWT. """ - secret: SecretStr + secret: SecretStr = constr(min_length=32) algorithm: str lifetime_user: int lifetime_sudo: int From 8da792bf66483fcecc55332be376e5bf7658911c Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Mon, 18 Mar 2024 14:33:56 -0400 Subject: [PATCH 5/9] Updated requirements --- requirements.txt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/requirements.txt b/requirements.txt index 1957628..806a7d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -48,7 +48,8 @@ pre-commit==3.6.2 pyasn1==0.5.1 pyasn1-modules==0.3.0 pycparser==2.21 -pydantic==1.10.10 +pydantic==2.6.4 +pydantic_settings==2.2.1 pyOpenSSL==22.1.0 python-dateutil==2.9.0.post0 python-dotenv==1.0.1 @@ -74,7 +75,3 @@ uvloop==0.19.0 virtualenv==20.25.1 watchfiles==0.21.0 websockets==12.0 -jinja2 -commonmark -redis -pydantic_settings From a5e9bc33ab62ad941d1bb7df252d5afca13d5aa0 Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Mon, 18 Mar 2024 14:35:44 -0400 Subject: [PATCH 6/9] Added logging statements --- index.py | 1 - util/options.py | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/index.py b/index.py index 1df18d4..4eabe1e 100644 --- a/index.py +++ b/index.py @@ -120,7 +120,6 @@ async def index(request: Request, token: Optional[str] = Cookie(None)): async def oauth_transformer(redir: str = "/join/2"): # Open redirect check hostname = urlparse(redir).netloc - print(hostname) if hostname != "" and hostname != Settings().http.domain: redir = "/join/2" diff --git a/util/options.py b/util/options.py index fe8d6c3..872de38 100644 --- a/util/options.py +++ b/util/options.py @@ -4,7 +4,9 @@ import yaml import json import subprocess +import logging +logger = logging.getLogger(__name__) def BitwardenConfig(settings_dict: dict): ''' @@ -12,11 +14,12 @@ def BitwardenConfig(settings_dict: dict): The bitwarden secrets are mapped to the settings dict using the bitwarden_mapping dict. The secrets are sourced based on a project id in the settings dict. ''' + logger.debug("Loading secrets from Bitwarden") try: command = f"bws secret list {settings_dict['bws']['project_id']}" bitwarden_raw = subprocess.check_output(command, shell=True, text=True) except Exception as e: - print(e) + logger.error(e) bitwarden_settings = parse_json_to_dict(bitwarden_raw) bitwarden_mapping = { From 19a458661e61978900b999f4822866a6ff4fceb7 Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Mon, 18 Mar 2024 14:40:55 -0400 Subject: [PATCH 7/9] appease the pre-commit gods --- config.yml | 10 +++++----- index.py | 4 ++-- routes/admin.py | 1 - routes/api.py | 8 +++----- routes/infra.py | 1 - routes/stripe.py | 1 - routes/wallet.py | 2 -- util/authentication.py | 4 +--- util/discord.py | 2 -- util/email.py | 1 - util/forms.py | 3 ++- util/options.py | 25 +++++++++++++------------ 12 files changed, 26 insertions(+), 36 deletions(-) diff --git a/config.yml b/config.yml index 055df64..055bc35 100644 --- a/config.yml +++ b/config.yml @@ -1,7 +1,7 @@ # Options for Onboard. bws: - project_id: - enable: + project_id: "" + enable: "" jwt: secret: algorithm: "HS256" @@ -29,9 +29,9 @@ discord: stripe: - api_key: - webhook_secret: - price_id: + api_key: "" + webhook_secret: "" + price_id: "" url_success: "https://join.hackucf.org/final/" url_failure: "https://join.hackucf.org/pay/" diff --git a/index.py b/index.py index 4eabe1e..f061429 100644 --- a/index.py +++ b/index.py @@ -26,18 +26,18 @@ from util.authentication import Authentication # Import error handling from util.errors import Errors +from util.forms import Forms # Import the page rendering library from util.kennelish import Kennelish # Import options from util.options import Settings -from util.forms import Forms ### TODO: TEMP os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "0" ### -logging.basicConfig(level=logging.DEBUG, +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(name)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S') logger = logging.getLogger(__name__) diff --git a/routes/admin.py b/routes/admin.py index 7996a77..792d03d 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -15,7 +15,6 @@ from util.errors import Errors from util.options import Settings - templates = Jinja2Templates(directory="templates") router = APIRouter(prefix="/admin", tags=["Admin"], responses=Errors.basic_http()) diff --git a/routes/api.py b/routes/api.py index 91262ed..91f21f8 100644 --- a/routes/api.py +++ b/routes/api.py @@ -3,7 +3,7 @@ import boto3 from botocore.exceptions import ClientError -from fastapi import APIRouter, Cookie, Request, HTTPException +from fastapi import APIRouter, Cookie, HTTPException, Request from fastapi.responses import HTMLResponse from pydantic import error_wrappers @@ -11,11 +11,9 @@ from models.user import PublicContact from util.authentication import Authentication from util.errors import Errors +from util.forms import Forms from util.kennelish import Kennelish, Transformer from util.options import Settings -from util.forms import Forms - - router = APIRouter(prefix="/api", tags=["API"], responses=Errors.basic_http()) @@ -103,7 +101,7 @@ async def post_form( kennelish_data = Forms.get_form_body(num) except FileNotFoundError: return HTTPException(status_code=404, detail="Form not found") - + model = Transformer.kennelish_to_pydantic(kennelish_data) # Parse and Validate inputs diff --git a/routes/infra.py b/routes/infra.py index efe7635..cf10688 100644 --- a/routes/infra.py +++ b/routes/infra.py @@ -21,7 +21,6 @@ from util.limiter import RateLimiter from util.options import Settings - logger = logging.getLogger(__name__) diff --git a/routes/stripe.py b/routes/stripe.py index 10551c7..a6eb4c3 100644 --- a/routes/stripe.py +++ b/routes/stripe.py @@ -13,7 +13,6 @@ from util.errors import Errors from util.options import Settings - templates = Jinja2Templates(directory="templates") diff --git a/routes/wallet.py b/routes/wallet.py index f3649fa..0887a1c 100644 --- a/routes/wallet.py +++ b/routes/wallet.py @@ -14,8 +14,6 @@ from util.errors import Errors from util.options import Settings - - router = APIRouter( prefix="/wallet", tags=["API", "MobileWallet"], responses=Errors.basic_http() ) diff --git a/util/authentication.py b/util/authentication.py index f59ea06..790bb60 100644 --- a/util/authentication.py +++ b/util/authentication.py @@ -11,8 +11,6 @@ from util.options import Settings - - class Authentication: def __init__(self): super(Authentication, self).__init__ @@ -102,7 +100,7 @@ async def wrapper_member( return tr else: raise # Re-raise exceptions that are not related to token validation - + if time.time() > creation_date + Settings().jwt.lifetime_user: return Errors.generate( request, diff --git a/util/discord.py b/util/discord.py index bec58fb..e0bf36f 100644 --- a/util/discord.py +++ b/util/discord.py @@ -4,8 +4,6 @@ from util.options import Settings - - headers = { "Authorization": f"Bot {Settings().discord.bot_token.get_secret_value()}", "Content-Type": "application/json", diff --git a/util/email.py b/util/email.py index c7e7ee4..843638f 100644 --- a/util/email.py +++ b/util/email.py @@ -6,7 +6,6 @@ from util.options import Settings - email = Settings().email.email password = Settings().email.password.get_secret_value() smtp_host = Settings().email.smtp_server diff --git a/util/forms.py b/util/forms.py index f1f0296..28f25c3 100644 --- a/util/forms.py +++ b/util/forms.py @@ -1,10 +1,11 @@ import json import os + class Forms: def get_form_body(file="1"): try: form_file = os.path.join(os.getcwd(), "forms", f"{file}.json") return json.load(open(form_file, "r")) except FileNotFoundError: - raise \ No newline at end of file + raise diff --git a/util/options.py b/util/options.py index 872de38..b381dbd 100644 --- a/util/options.py +++ b/util/options.py @@ -1,10 +1,11 @@ +import json +import logging import os +import subprocess + +import yaml from pydantic import BaseModel, SecretStr, constr from pydantic_settings import BaseSettings -import yaml -import json -import subprocess -import logging logger = logging.getLogger(__name__) @@ -21,7 +22,7 @@ def BitwardenConfig(settings_dict: dict): except Exception as e: logger.error(e) bitwarden_settings = parse_json_to_dict(bitwarden_raw) - + bitwarden_mapping = { 'discord_bot_token': ('discord', 'bot_token'), 'discord_client_id': ('discord', 'client_id'), @@ -35,7 +36,7 @@ def BitwardenConfig(settings_dict: dict): 'infra_application_credential_id': ('infra', 'application_credential_id'), 'infra_configuration_credential_secret': ('infra', 'application_credential_secret') } - + bitwarden_mapped = {} for bw_key, nested_keys in bitwarden_mapping.items(): if bw_key in bitwarden_settings: @@ -43,13 +44,13 @@ def BitwardenConfig(settings_dict: dict): if top_key not in bitwarden_mapped: bitwarden_mapped[top_key] = {} bitwarden_mapped[top_key][nested_key] = bitwarden_settings[bw_key] - + for top_key, nested_dict in bitwarden_mapped.items(): if top_key in settings_dict: for nested_key, value in nested_dict.items(): settings_dict[top_key][nested_key] = value return settings_dict - + settings_dict = dict() # Reads config from ../config/options.yml @@ -103,7 +104,7 @@ class StripeConfig(BaseModel): price_id: str url_success: str url_failure: str -stripe_config = StripeConfig(**settings_dict['stripe']) +stripe_config = StripeConfig(**settings_dict['stripe']) class EmailConfig(BaseModel): """ @@ -114,10 +115,10 @@ class EmailConfig(BaseModel): email (str): The email address to send from also used as the login username. password (SecretStr): The password for the email account. """ - smtp_server: str + smtp_server: str email: str password: SecretStr -email_config = EmailConfig(**settings_dict['email']) +email_config = EmailConfig(**settings_dict['email']) class JwtConfig(BaseModel): """ @@ -164,7 +165,7 @@ class RedisConfig(BaseModel): redis_config = RedisConfig(**settings_dict['redis']) class HttpConfig(BaseModel): - domain: str + domain: str http_config = HttpConfig(**settings_dict['http']) class SingletonBaseSettingsMeta(type(BaseSettings), type): From 761ba0dfa09d858e88fbce32ae2ec6dcb00da73f Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Mon, 18 Mar 2024 18:07:21 -0400 Subject: [PATCH 8/9] Renamed options.py to settings.py fixed command injection in settings module --- util/{options.py => settings.py} | 42 +++++++++++++++++--------------- 1 file changed, 23 insertions(+), 19 deletions(-) rename util/{options.py => settings.py} (86%) diff --git a/util/options.py b/util/settings.py similarity index 86% rename from util/options.py rename to util/settings.py index b381dbd..12e4c25 100644 --- a/util/options.py +++ b/util/settings.py @@ -1,6 +1,7 @@ import json import logging import os +import re import subprocess import yaml @@ -9,7 +10,7 @@ logger = logging.getLogger(__name__) -def BitwardenConfig(settings_dict: dict): +def BitwardenConfig(settings: dict): ''' Takes a dict of settings loaded from yaml and adds the secrets from bitwarden to the settings dict. The bitwarden secrets are mapped to the settings dict using the bitwarden_mapping dict. @@ -17,10 +18,13 @@ def BitwardenConfig(settings_dict: dict): ''' logger.debug("Loading secrets from Bitwarden") try: - command = f"bws secret list {settings_dict['bws']['project_id']}" + project_id = settings['bws']['project_id'] + if bool(re.search('[^a-z0-9-]', project_id)): + raise ValueError("Invalid project id") + command = f"bws secret list {project_id}" bitwarden_raw = subprocess.check_output(command, shell=True, text=True) except Exception as e: - logger.error(e) + logger.exception(e) bitwarden_settings = parse_json_to_dict(bitwarden_raw) bitwarden_mapping = { @@ -46,24 +50,24 @@ def BitwardenConfig(settings_dict: dict): bitwarden_mapped[top_key][nested_key] = bitwarden_settings[bw_key] for top_key, nested_dict in bitwarden_mapped.items(): - if top_key in settings_dict: + if top_key in settings: for nested_key, value in nested_dict.items(): - settings_dict[top_key][nested_key] = value - return settings_dict + settings[top_key][nested_key] = value + return settings -settings_dict = dict() +settings = dict() # Reads config from ../config/options.yml here = os.path.abspath(os.path.dirname(__file__)) with open(os.path.join(here, "../config/options.yml")) as f: - settings_dict.update(yaml.load(f, Loader=yaml.FullLoader)) + settings.update(yaml.load(f, Loader=yaml.FullLoader)) def parse_json_to_dict(json_string): data = json.loads(json_string) return {item['key']: item['value'] for item in data} -# If bitwarden is enabled, add secrets to settings_dict -if settings_dict.get('bws').get('enable'): - settings_dict = BitwardenConfig(settings_dict) +# If bitwarden is enabled, add secrets to settings +if settings.get('bws').get('enable'): + settings = BitwardenConfig(settings) class DiscordConfig(BaseModel): @@ -87,7 +91,7 @@ class DiscordConfig(BaseModel): scope: str secret: SecretStr -discord_config = DiscordConfig(**settings_dict['discord']) +discord_config = DiscordConfig(**settings['discord']) class StripeConfig(BaseModel): """ Configuration class for Stripe integration. @@ -104,7 +108,7 @@ class StripeConfig(BaseModel): price_id: str url_success: str url_failure: str -stripe_config = StripeConfig(**settings_dict['stripe']) +stripe_config = StripeConfig(**settings['stripe']) class EmailConfig(BaseModel): """ @@ -118,7 +122,7 @@ class EmailConfig(BaseModel): smtp_server: str email: str password: SecretStr -email_config = EmailConfig(**settings_dict['email']) +email_config = EmailConfig(**settings['email']) class JwtConfig(BaseModel): """ @@ -134,11 +138,11 @@ class JwtConfig(BaseModel): algorithm: str lifetime_user: int lifetime_sudo: int -jwt_config = JwtConfig(**settings_dict['jwt']) +jwt_config = JwtConfig(**settings['jwt']) class DynamodbConfig(BaseModel): table: str -dynamodb_config = DynamodbConfig(**settings_dict['aws']['dynamodb']) +dynamodb_config = DynamodbConfig(**settings['aws']['dynamodb']) class InfraConfig(BaseModel): """ @@ -156,17 +160,17 @@ class InfraConfig(BaseModel): application_credential_id: str application_credential_secret: SecretStr tf_directory: str -infra_config = InfraConfig(**settings_dict['infra']) +infra_config = InfraConfig(**settings['infra']) class RedisConfig(BaseModel): host: str port: int db: int -redis_config = RedisConfig(**settings_dict['redis']) +redis_config = RedisConfig(**settings['redis']) class HttpConfig(BaseModel): domain: str -http_config = HttpConfig(**settings_dict['http']) +http_config = HttpConfig(**settings['http']) class SingletonBaseSettingsMeta(type(BaseSettings), type): _instances = {} From 32af7887a2ff35c179571cd68971c3cf31d31702 Mon Sep 17 00:00:00 2001 From: Jonathan Styles Date: Mon, 18 Mar 2024 18:09:53 -0400 Subject: [PATCH 9/9] fixup! Renamed options.py to settings.py fixed command injection in settings module --- index.py | 2 +- routes/admin.py | 2 +- routes/api.py | 2 +- routes/infra.py | 2 +- routes/stripe.py | 2 +- routes/wallet.py | 2 +- util/approve.py | 2 +- util/authentication.py | 2 +- util/discord.py | 2 +- util/email.py | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/index.py b/index.py index f061429..315abcc 100644 --- a/index.py +++ b/index.py @@ -30,7 +30,7 @@ # Import the page rendering library from util.kennelish import Kennelish # Import options -from util.options import Settings +from util.settings import Settings ### TODO: TEMP os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "0" diff --git a/routes/admin.py b/routes/admin.py index 792d03d..0522300 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -13,7 +13,7 @@ from util.discord import Discord from util.email import Email from util.errors import Errors -from util.options import Settings +from util.settings import Settings templates = Jinja2Templates(directory="templates") diff --git a/routes/api.py b/routes/api.py index 91f21f8..dea1305 100644 --- a/routes/api.py +++ b/routes/api.py @@ -13,7 +13,7 @@ from util.errors import Errors from util.forms import Forms from util.kennelish import Kennelish, Transformer -from util.options import Settings +from util.settings import Settings router = APIRouter(prefix="/api", tags=["API"], responses=Errors.basic_http()) diff --git a/routes/infra.py b/routes/infra.py index cf10688..bed924e 100644 --- a/routes/infra.py +++ b/routes/infra.py @@ -19,7 +19,7 @@ from util.email import Email from util.errors import Errors from util.limiter import RateLimiter -from util.options import Settings +from util.settings import Settings logger = logging.getLogger(__name__) diff --git a/routes/stripe.py b/routes/stripe.py index a6eb4c3..8dcf859 100644 --- a/routes/stripe.py +++ b/routes/stripe.py @@ -11,7 +11,7 @@ from util.approve import Approve from util.authentication import Authentication from util.errors import Errors -from util.options import Settings +from util.settings import Settings templates = Jinja2Templates(directory="templates") diff --git a/routes/wallet.py b/routes/wallet.py index 0887a1c..4ebb59c 100644 --- a/routes/wallet.py +++ b/routes/wallet.py @@ -12,7 +12,7 @@ from models.user import PublicContact from util.authentication import Authentication from util.errors import Errors -from util.options import Settings +from util.settings import Settings router = APIRouter( prefix="/wallet", tags=["API", "MobileWallet"], responses=Errors.basic_http() diff --git a/util/approve.py b/util/approve.py index 2636944..aeb362b 100644 --- a/util/approve.py +++ b/util/approve.py @@ -8,7 +8,7 @@ from util.discord import Discord from util.email import Email from util.horsepass import HorsePass -from util.options import Settings +from util.settings import Settings logger = logging.getLogger() diff --git a/util/authentication.py b/util/authentication.py index 790bb60..60f4dc9 100644 --- a/util/authentication.py +++ b/util/authentication.py @@ -8,7 +8,7 @@ # Import options and errors from util.errors import Errors -from util.options import Settings +from util.settings import Settings class Authentication: diff --git a/util/discord.py b/util/discord.py index e0bf36f..3f1e71e 100644 --- a/util/discord.py +++ b/util/discord.py @@ -2,7 +2,7 @@ import requests -from util.options import Settings +from util.settings import Settings headers = { "Authorization": f"Bot {Settings().discord.bot_token.get_secret_value()}", diff --git a/util/email.py b/util/email.py index 843638f..8f36c53 100644 --- a/util/email.py +++ b/util/email.py @@ -4,7 +4,7 @@ import commonmark -from util.options import Settings +from util.settings import Settings email = Settings().email.email password = Settings().email.password.get_secret_value()