Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7a9d4b1
fix: change session ID to be generated from 32 bytes of entropy
jbriones1 Jul 1, 2026
1921ca3
fix: remove site profile pics
jbriones1 Jul 1, 2026
7454551
feat: rewrite login
jbriones1 Jul 1, 2026
ba494e9
feat: environment variable support
jbriones1 Jul 1, 2026
f7d66c6
fix: make logout idempotent
jbriones1 Jul 1, 2026
cb47eb9
refactor: change the variables of create_user_session
jbriones1 Jul 1, 2026
c856202
feat: creating a user session now uses upserts
jbriones1 Jul 1, 2026
2c8425b
fix: cookie wouldn't be delete if the domain was set
jbriones1 Jul 1, 2026
831ebe7
feat: improve auth login handling
jbriones1 Jul 1, 2026
871d842
fix: let client handle post authentication
jbriones1 Jul 1, 2026
4f6b4a1
fix: status codes in auth module
jbriones1 Jul 1, 2026
9e33d93
fix: no session provided still deleted the cookie
jbriones1 Jul 1, 2026
d7a3473
feat(cookie): add MAX-AGE of 2 hours
jbriones1 Jul 2, 2026
85bf656
fix: removed unused import
jbriones1 Jul 2, 2026
f94114e
fix: made session expiration consistently 2 hours
jbriones1 Jul 2, 2026
ab8ae26
feat: remove 401 from logout if user is missing the session cookie
jbriones1 Jul 2, 2026
200a808
fix: expired sessions were still usable
jbriones1 Jul 2, 2026
12713ab
fix: added the missing status code to /auth/login
jbriones1 Jul 2, 2026
84f24cf
fix: frontend origin removed from /auth/login
jbriones1 Jul 2, 2026
55040ae
chore: move .env to root
jbriones1 Jul 2, 2026
a566d83
chore: removed some dead code
jbriones1 Jul 2, 2026
ca55b6b
feat: add auth tests and update pytest config
jbriones1 Jul 2, 2026
8428a09
ci: update workflows to use uv and fix env vars
jbriones1 Jul 2, 2026
4b31a32
fix: `.env` in root can be in sub-directories
jbriones1 Jul 3, 2026
59cbd3b
fix: test runner failure due to date difference
jbriones1 Jul 3, 2026
7e4ad49
fix: database setup does not respect the app settings
jbriones1 Jul 3, 2026
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
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Required
# Supports `dev`, `prod`, or `test`
ENVIRONMENT=dev
DB_PORT=5444

# Authentication
COOKIE_SECURE=false
AUTH_URL=https://cas.sfu.ca/cas/serviceValidate

# Redirect after authentication
FRONTEND_ORIGIN=http://localhost:8080
32 changes: 18 additions & 14 deletions .github/workflows/alembic.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ on: pull_request
jobs:
alembic_upgrade_head:
runs-on: ubuntu-latest
env:
ENVIRONMENT: dev
DB_PORT: "5432"
COOKIE_SECURE: "false"
FRONTEND_ORIGIN: http://localhost:8080

services:
postgres:
Expand Down Expand Up @@ -32,19 +37,18 @@ jobs:
sleep 1
done

- uses: astral-sh/setup-uv@v6
with:
enable-cache: true

- name: Install Python
run: uv python install 3.13

- name: Install dependencies
run: |
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.13 python3.13-venv
python3.13 -m pip install --upgrade pip
python3.13 -m venv venv
source ./venv/bin/activate
pip install .

# This will fail if there are divergent heads and alembic gets confused;
# e.g., un-sanitarily merging main into a dev branch.
run: uv sync --locked

# This will fail if there are divergent heads and alembic gets confused
# e.g. merging into main without passing this check
- name: Run alembic upgrade head
run: |
source ./venv/bin/activate
cd src
alembic upgrade head
working-directory: src
run: uv run alembic upgrade head
11 changes: 7 additions & 4 deletions .github/workflows/pytest_unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,21 @@ jobs:
unit_tests:
runs-on: ubuntu-latest
timeout-minutes: 5
env:
ENVIRONMENT: test
COOKIE_SECURE: "false"
FRONTEND_ORIGIN: http://localhost:8080

steps:
- uses: actions/checkout@v6

- uses: actions/setup-python@v6
with:
python-version: '3.13'

- uses: astral-sh/setup-uv@v6
with:
enable-cache: true

- name: Install Python
run: uv python install 3.13

- name: Install dependencies
run: uv sync --extra test --locked

Expand Down
14 changes: 13 additions & 1 deletion .github/workflows/ruff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: astral-sh/ruff-action@v3

- uses: astral-sh/setup-uv@v6
with:
enable-cache: true

- name: Install Python
run: uv python install 3.13

- name: Install dependencies
run: uv sync --extra dev --locked

- name: Run Ruff
run: uv run ruff check .
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,26 +33,27 @@ pip install ".[dev, test]" # or: uv sync --all-extras
```

5. Follow the database setup instructions on the [wiki](https://github.com/CSSS/csss-site-backend/wiki/1.-Local-Setup#database-setup). The recommended way is to do it through Docker, but both should work.
6. You will need to set the following environment variables
6. You will need to set the following environment variables set
```bash
export DB_PORT=5444 # If you're using Docker
export LOCAL=true # Should be true if you're running this locally
DB_PORT=5444 # If you're using Docker
ENVIRONMENT=dev # Set this to `test` if you want to use the test database instead
```
You can also create a `.env` file and set those in there. See `.env.example` for more information.


## Important Directories

- `config/` configuration files for the server machine
- `src/`
- `alembic` for database migrations
- `access/` for controlling officer access to the google drive, bitwarden, and github. TODO: discord as well?
- `blog/` for running an editable csss-blog
- `dashboard/` for controlling the server, website, jobs, and access to services & stuff.
- `elections/` for the mangement of current elections & past elections
- `jobs/` for cronjobs that run regularly on the server
- `misc/` for anything that can't be easily categorized or is very small
- `officers/` for officer contact information + photos
- `test/` for html pages which interact with the backend's local api
- `alembic` database migrations
- `auth/` controlling authentication and sessions
- `candidates/` management of those who run in large elections
- `elections/` mangement of current elections & past elections
- `event/` management of events
- `nominees/` management of nominees to large elections
- `officers/` management of officer information and terms
- `translink/` management of TransLink REST API
- `test/` unit and integration tests

## Developer Tools

Expand Down
18 changes: 13 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,22 @@ Homepage = "https://api.sfucsss.org/"
# Pytest: Test framework
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
norecursedirs = ["tests/wip"] # Don't test these paths
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
asyncio_default_test_loop_scope = "function"
log_cli = true
log_cli_level = "INFO"
testpaths = [
"tests",
addopts = [
"--strict-markers",
"--strict-config",
"--tb=short"
]
markers = [
"integration: tests that require database or full app setup",
"unit: isolated tests that do not require external services"
]
norecursedirs = "tests/wip"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"

# Ruff: Formatter and linter
[tool.ruff]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""change session ID to be 32 bytes of base64

Revision ID: 060623cf940f
Revises: f0c99d0db277
Create Date: 2026-07-01 01:17:28.430756

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

from constants import SESSION_ID_LEN


# revision identifiers, used by Alembic.
revision: str = '060623cf940f'
down_revision: Union[str, None] = 'f0c99d0db277'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('user_session', 'session_id',
existing_type=sa.VARCHAR(length=512),
type_=sa.String(length=SESSION_ID_LEN),
existing_nullable=False)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('user_session', 'session_id',
existing_type=sa.String(length=SESSION_ID_LEN),
type_=sa.VARCHAR(length=512),
existing_nullable=False)
# ### end Alembic commands ###
30 changes: 30 additions & 0 deletions src/alembic/versions/caa26d56d842_remove_site_profile_pics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""remove site profile pics

Revision ID: caa26d56d842
Revises: 060623cf940f
Create Date: 2026-07-01 02:01:26.412179

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = 'caa26d56d842'
down_revision: Union[str, None] = '060623cf940f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('site_user', 'profile_picture_url')
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('site_user', sa.Column('profile_picture_url', sa.TEXT(), autoincrement=False, nullable=True))
# ### end Alembic commands ###
1 change: 0 additions & 1 deletion src/auth/__init__.py

This file was deleted.

5 changes: 5 additions & 0 deletions src/auth/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from datetime import timedelta

COOKIE_SESSION_KEY = "session_id"
COOKIE_MAX_AGE = 60 * 60 * 2 # 2 hours in seconds
SESSION_MAX_AGE = timedelta(seconds=COOKIE_MAX_AGE)
106 changes: 51 additions & 55 deletions src/auth/crud.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import logging
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime

import sqlalchemy
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession

from auth.constants import SESSION_MAX_AGE
from auth.tables import SiteUserDB, UserSessionDB

_logger = logging.getLogger(__name__)
Expand All @@ -15,56 +17,66 @@ async def create_user_session(db_session: AsyncSession, session_id: str, computi

Also, adds the new user to the SiteUser table if it's their first time logging in.
"""
existing_user_session = await db_session.scalar(
sqlalchemy.select(UserSessionDB).where(UserSessionDB.computing_id == computing_id)
now = datetime.now(UTC)

# Upsert the site user
# Create a new user if it's their first login...
user_query = insert(SiteUserDB).values(
computing_id=computing_id,
first_logged_in=now,
last_logged_in=now,
)
existing_user = await db_session.scalar(
sqlalchemy.select(SiteUserDB).where(SiteUserDB.computing_id == computing_id)
# ...or just update their "last_logged_in" time
user_query = user_query.on_conflict_do_update(
index_elements=[SiteUserDB.computing_id], set_={"last_logged_in": now}
)

if existing_user is None:
if existing_user_session is not None:
# log this strange case that shouldn't be possible
_logger.warning(f"User session {session_id} exists for non-existent user {computing_id} ... !")

# add new user to User table if it's their first time logging in
db_session.add(
SiteUserDB(computing_id=computing_id, first_logged_in=datetime.now(UTC), last_logged_in=datetime.now(UTC))
)

if existing_user_session is not None:
existing_user_session.issue_time = datetime.now(UTC)
existing_user_session.session_id = session_id
if existing_user is not None:
# update the last time the user logged in to now
existing_user.last_logged_in = datetime.now(UTC)
else:
db_session.add(
UserSessionDB(
session_id=session_id,
computing_id=computing_id,
issue_time=datetime.now(UTC),
)
)
await db_session.execute(user_query)

# Upsert the user session
# Create a new session...
session_query = insert(UserSessionDB).values(
session_id=session_id,
computing_id=computing_id,
issue_time=now,
)
# ...or update their current session
session_query = session_query.on_conflict_do_update(
index_elements=[UserSessionDB.computing_id], set_={"session_id": session_id, "issue_time": now}
)
await db_session.execute(session_query)


async def remove_user_session(db_session: AsyncSession, session_id: str):
query = sqlalchemy.select(UserSessionDB).where(UserSessionDB.session_id == session_id)
user_session = await db_session.scalars(query)
await db_session.delete(user_session.first())
user_session = await db_session.scalar(query)
if user_session is not None:
await db_session.delete(user_session)


async def get_session_computing_id(db_session: AsyncSession, session_id: str) -> str | None:
"""
Retrieves the computing ID from a session.

Args:
db_session: database transaction
session_id: session ID the computing ID is using

async def get_computing_id(db_session: AsyncSession, session_id: str) -> str | None:
Returns:
The computing ID associated with the session, or None if the session is invalid or expired.
"""
query = sqlalchemy.select(UserSessionDB).where(UserSessionDB.session_id == session_id)
existing_user_session = (await db_session.scalars(query)).first()
return existing_user_session.computing_id if existing_user_session else None
if existing_user_session is None or existing_user_session.issue_time < datetime.now(UTC) - SESSION_MAX_AGE:
return None

return existing_user_session.computing_id


# remove all out of date user sessions
async def task_clean_expired_user_sessions(db_session: AsyncSession):
one_day_ago = datetime.now(UTC) - timedelta(days=0.5)
expiration = datetime.now(UTC) - SESSION_MAX_AGE

query = sqlalchemy.delete(UserSessionDB).where(UserSessionDB.issue_time < one_day_ago)
query = sqlalchemy.delete(UserSessionDB).where(UserSessionDB.issue_time < expiration)
await db_session.execute(query)
await db_session.commit()

Expand All @@ -73,30 +85,14 @@ async def task_clean_expired_user_sessions(db_session: AsyncSession):
async def get_site_user(db_session: AsyncSession, session_id: str) -> SiteUserDB | None:
query = sqlalchemy.select(UserSessionDB).where(UserSessionDB.session_id == session_id)
user_session = await db_session.scalar(query)
if user_session is None:

if user_session is None or user_session.issue_time < datetime.now(UTC) - SESSION_MAX_AGE:
return None

query = sqlalchemy.select(SiteUserDB).where(SiteUserDB.computing_id == user_session.computing_id)
return await db_session.scalar(query)


async def site_user_exists(db_session: AsyncSession, computing_id: str) -> bool:
user = await db_session.scalar(sqlalchemy.select(SiteUserDB).where(SiteUserDB.computing_id == computing_id))
user = await db_session.get(SiteUserDB, computing_id)
return user is not None


# update the optional user info for a given site user (e.g., display name, profile picture, ...)
async def update_site_user(db_session: AsyncSession, session_id: str, profile_picture_url: str) -> bool:
query = sqlalchemy.select(UserSessionDB).where(UserSessionDB.session_id == session_id)
user_session = await db_session.scalar(query)
if user_session is None:
return False

query = (
sqlalchemy.update(SiteUserDB)
.where(SiteUserDB.computing_id == user_session.computing_id)
.values(profile_picture_url=profile_picture_url)
)
await db_session.execute(query)

return True
Loading
Loading