Skip to content

Commit 4091bb2

Browse files
authored
Refactor login flow (#114)
* chore: add PyRight configuration * wip: add CORS * wip: changed login model * fix: CORS header and made cookie cache the samesite and domain value * fix: address code review comments * fix: linter caught an error * fix: flipped the IS_PROD logic
1 parent fd23837 commit 4091bb2

File tree

6 files changed

+85
-32
lines changed

6 files changed

+85
-32
lines changed

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,8 @@ line-ending = "lf"
2626

2727
[tool.ruff.lint]
2828
select = ["E", "F", "B", "I", "N", "UP", "A", "PTH", "W", "RUF", "C4", "PIE", "Q", "FLY"] # "ANN"
29-
ignore = ["E501", "F401", "N806"]
29+
ignore = ["E501", "F401", "N806"]
30+
31+
[tool.pyright]
32+
executionEnvironments = [{ root = "src" }]
33+
typeCheckingMode = "standard"

src/auth/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from pydantic import BaseModel, Field
2+
3+
4+
class LoginBodyModel(BaseModel):
5+
service: str = Field(description="Service URL used for SFU's CAS system")
6+
ticket: str = Field(description="Ticket return from SFU's CAS system")
7+
redirect_url: str | None = Field(None, description="Optional redirect URL")

src/auth/urls.py

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55

66
import requests # TODO: make this async
77
import xmltodict
8-
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
8+
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response
99
from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse
1010

1111
import database
1212
from auth import crud
13-
from constants import FRONTEND_ROOT_URL
13+
from auth.models import LoginBodyModel
14+
from constants import DOMAIN, IS_PROD, SAMESITE
15+
from utils.shared_models import DetailModel
1416

1517
_logger = logging.getLogger(__name__)
1618

@@ -32,27 +34,34 @@ def generate_session_id_b64(num_bytes: int) -> str:
3234
)
3335

3436

35-
# NOTE: logging in a second time invaldiates the last session_id
36-
@router.get(
37+
# NOTE: logging in a second time invalidates the last session_id
38+
@router.post(
3739
"/login",
38-
description="Login to the sfucsss.org. Must redirect to this endpoint from SFU's cas authentication service for correct parameters",
40+
description="Create a login session.",
41+
response_description="Successfully validated with SFU's CAS",
42+
response_model=str,
43+
responses={
44+
307: { "description": "Successful validation, with redirect" },
45+
400: { "description": "Origin is missing.", "model": DetailModel },
46+
401: { "description": "Failed to validate ticket with SFU's CAS", "model": DetailModel }
47+
},
48+
operation_id="login",
3949
)
4050
async def login_user(
41-
redirect_path: str,
42-
redirect_fragment: str,
43-
ticket: str,
51+
request: Request,
4452
db_session: database.DBSession,
4553
background_tasks: BackgroundTasks,
54+
body: LoginBodyModel
4655
):
4756
# verify the ticket is valid
48-
service = urllib.parse.quote(f"{FRONTEND_ROOT_URL}/api/auth/login?redirect_path={redirect_path}&redirect_fragment={redirect_fragment}")
49-
service_validate_url = f"https://cas.sfu.ca/cas/serviceValidate?service={service}&ticket={ticket}"
57+
service_url = body.service
58+
service = urllib.parse.quote(service_url)
59+
service_validate_url = f"https://cas.sfu.ca/cas/serviceValidate?service={service}&ticket={body.ticket}"
5060
cas_response = xmltodict.parse(requests.get(service_validate_url).text)
5161

5262
if "cas:authenticationFailure" in cas_response["cas:serviceResponse"]:
5363
_logger.info(f"User failed to login, with response {cas_response}")
54-
raise HTTPException(status_code=401, detail="authentication error, ticket likely invalid")
55-
64+
raise HTTPException(status_code=401, detail="authentication error")
5665
else:
5766
session_id = generate_session_id_b64(256)
5867
computing_id = cas_response["cas:serviceResponse"]["cas:authenticationSuccess"]["cas:user"]
@@ -63,15 +72,29 @@ async def login_user(
6372
# clean old sessions after sending the response
6473
background_tasks.add_task(crud.task_clean_expired_user_sessions, db_session)
6574

66-
response = RedirectResponse(FRONTEND_ROOT_URL + redirect_path + "#" + redirect_fragment)
75+
if body.redirect_url:
76+
origin = request.headers.get("origin")
77+
if origin:
78+
response = RedirectResponse(origin + body.redirect_url)
79+
else:
80+
raise HTTPException(status_code=400, detail="bad origin")
81+
else:
82+
response = Response()
83+
6784
response.set_cookie(
68-
key="session_id", value=session_id
85+
key="session_id",
86+
value=session_id,
87+
secure=IS_PROD,
88+
httponly=True,
89+
samesite=SAMESITE,
90+
domain=DOMAIN
6991
) # this overwrites any past, possibly invalid, session_id
7092
return response
7193

7294

7395
@router.get(
7496
"/logout",
97+
operation_id="logout",
7598
description="Logs out the current user by invalidating the session_id cookie",
7699
)
77100
async def logout_user(
@@ -94,6 +117,7 @@ async def logout_user(
94117

95118
@router.get(
96119
"/user",
120+
operation_id="get_user",
97121
description="Get info about the current user. Only accessible by that user",
98122
)
99123
async def get_user(
@@ -116,6 +140,7 @@ async def get_user(
116140

117141
@router.patch(
118142
"/user",
143+
operation_id="update_user",
119144
description="Update information for the currently logged in user. Only accessible by that user",
120145
)
121146
async def update_user(

src/constants.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
# TODO(future): replace new.sfucsss.org with sfucsss.org during migration
44
# TODO(far-future): branch-specific root IP addresses (e.g., devbranch.sfucsss.org)
5-
FRONTEND_ROOT_URL = "http://localhost:8080" if os.environ.get("LOCAL") == "true" else "https://new.sfucsss.org"
6-
GITHUB_ORG_NAME = "CSSS-Test-Organization" if os.environ.get("LOCAL") == "true" else "CSSS"
5+
ENV_LOCAL = os.environ.get("LOCAL")
6+
IS_PROD = True if not ENV_LOCAL or ENV_LOCAL.lower() != "true" else False
7+
GITHUB_ORG_NAME = "CSSS-Test-Organization" if not IS_PROD else "CSSS"
78

89
W3_GUILD_ID = "1260652618875797504"
910
CSSS_GUILD_ID = "228761314644852736"
10-
ACTIVE_GUILD_ID = W3_GUILD_ID if os.environ.get("LOCAL") == "true" else CSSS_GUILD_ID
11+
ACTIVE_GUILD_ID = W3_GUILD_ID if not IS_PROD else CSSS_GUILD_ID
1112

1213
SESSION_ID_LEN = 512
1314
# technically a max of 8 digits https://www.sfu.ca/computing/about/support/tips/sfu-userid.html
@@ -25,3 +26,7 @@
2526

2627
# https://docs.github.com/en/[email protected]/admin/identity-and-access-management/iam-configuration-reference/username-considerations-for-external-authentication
2728
GITHUB_USERNAME_LEN = 39
29+
30+
# COOKIE
31+
SAMESITE="none" if IS_PROD else "lax"
32+
DOMAIN=".sfucsss.org" if IS_PROD else None

src/main.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,57 @@
11
import logging
2-
import os
32

43
from fastapi import FastAPI, Request, status
54
from fastapi.encoders import jsonable_encoder
65
from fastapi.exceptions import RequestValidationError
6+
from fastapi.middleware.cors import CORSMiddleware
77
from fastapi.responses import JSONResponse
88

99
import auth.urls
1010
import database
1111
import elections.urls
1212
import officers.urls
1313
import permission.urls
14+
from constants import IS_PROD
1415

1516
logging.basicConfig(level=logging.DEBUG)
1617
database.setup_database()
1718

18-
_login_link = (
19-
"https://cas.sfu.ca/cas/login?service=" + (
20-
"http%3A%2F%2Flocalhost%3A8080"
21-
if os.environ.get("LOCAL") == "true"
22-
else "https%3A%2F%2Fnew.sfucsss.org"
23-
) + "%2Fapi%2Fauth%2Flogin%3Fredirect_path%3D%2Fapi%2Fapi%2Fdocs%2F%26redirect_fragment%3D"
24-
)
25-
2619
# Enable OpenAPI docs only for local development
27-
if os.environ.get("LOCAL") == "true":
20+
if not IS_PROD:
21+
print("Running local environment")
22+
origins = [
23+
"http://localhost:4200", # default Angular
24+
"http://localhost:8080", # for existing applications/sites
25+
]
2826
app = FastAPI(
2927
lifespan=database.lifespan,
3028
title="CSSS Site Backend",
31-
description=f'To login, please click <a href="{_login_link}">here</a><br><br>To logout from CAS click <a href="https://cas.sfu.ca/cas/logout">here</a>',
3229
root_path="/api",
3330
)
34-
# if on production, disable vieweing the docs
31+
# if on production, disable viewing the docs
3532
else:
33+
print("Running production environment")
34+
origins = [
35+
"https://sfucsss.org",
36+
"https://test.sfucsss.org",
37+
"https://admin.sfucsss.org"
38+
]
3639
app = FastAPI(
3740
lifespan=database.lifespan,
3841
title="CSSS Site Backend",
39-
description=f'To login, please click <a href="{_login_link}">here</a><br><br>To logout from CAS click <a href="https://cas.sfu.ca/cas/logout">here</a>',
4042
root_path="/api",
4143
docs_url=None, # disables Swagger UI
4244
redoc_url=None, # disables ReDoc
4345
openapi_url=None # disables OpenAPI schema
4446
)
4547

48+
app.add_middleware(
49+
CORSMiddleware,
50+
allow_origins=origins,
51+
allow_credentials=True,
52+
allow_methods=["*"],
53+
allow_headers=["*"]
54+
)
4655

4756
app.include_router(auth.urls.router)
4857
app.include_router(elections.urls.router)
@@ -55,7 +64,7 @@ async def read_root():
5564

5665
@app.exception_handler(RequestValidationError)
5766
async def validation_exception_handler(
58-
request: Request,
67+
_: Request,
5968
exception: RequestValidationError,
6069
):
6170
return JSONResponse(

src/utils/shared_models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@
33

44
class SuccessFailModel(BaseModel):
55
success: bool
6+
7+
class DetailModel(BaseModel):
8+
detail: str

0 commit comments

Comments
 (0)