Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
cc35a2b
Refactor membership checking
MattyTheHacker Aug 31, 2025
0917ae5
Refactor and Reformat
MattyTheHacker Aug 31, 2025
2270e9c
Fix import error
MattyTheHacker Aug 31, 2025
fdda120
Bit of a mess
MattyTheHacker Aug 31, 2025
1ed9d9f
Formatting
MattyTheHacker Aug 31, 2025
797f94d
Fix
MattyTheHacker Aug 31, 2025
2ac933d
Reformat
MattyTheHacker Aug 31, 2025
74e57a6
Revert accidental change
MattyTheHacker Aug 31, 2025
fc6c1aa
Revert
MattyTheHacker Aug 31, 2025
f324842
Add logging
MattyTheHacker Aug 31, 2025
00a7121
Simplify logic
MattyTheHacker Sep 1, 2025
363e315
Refactor auth token checking
MattyTheHacker Sep 1, 2025
1c1722f
Remove unused variables
MattyTheHacker Sep 1, 2025
5670cba
Merge main into msl-auth
automatic-pr-updater[bot] Sep 2, 2025
26dcf70
Merge main into msl-auth
automatic-pr-updater[bot] Sep 2, 2025
ca9f7fb
Strip
MattyTheHacker Sep 3, 2025
6c976d3
remove old import
MattyTheHacker Sep 3, 2025
c466e69
Merge main into msl-auth
automatic-pr-updater[bot] Sep 4, 2025
ec9b323
Merge main into msl-auth
automatic-pr-updater[bot] Sep 4, 2025
3886f61
Merge main into msl-auth
automatic-pr-updater[bot] Sep 5, 2025
104da07
Merge main into msl-auth
automatic-pr-updater[bot] Sep 5, 2025
2aae19e
Merge branch 'main' into msl-auth
MattyTheHacker Oct 7, 2025
44d8215
Fix mypy
MattyTheHacker Oct 7, 2025
7a50739
Merge main into msl-auth
automatic-pr-updater[bot] Oct 8, 2025
508120b
Merge main into msl-auth
automatic-pr-updater[bot] Oct 13, 2025
be46790
Merge main into msl-auth
automatic-pr-updater[bot] Oct 14, 2025
cdfbf13
Merge main into msl-auth
automatic-pr-updater[bot] Oct 16, 2025
1f59415
Merge branch 'main' into msl-auth
MattyTheHacker Oct 28, 2025
92aee59
Fix spacing
MattyTheHacker Oct 28, 2025
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
137 changes: 7 additions & 130 deletions cogs/check_su_platform_authorisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,18 @@
from enum import Enum
from typing import TYPE_CHECKING, override

import aiohttp
import bs4
import discord
from discord.ext import tasks

from config import settings
from utils import GLOBAL_SSL_CONTEXT, CommandChecks, TeXBotBaseCog
from utils import CommandChecks, TeXBotBaseCog
from utils.error_capture_decorators import (
capture_guild_does_not_exist_error,
)
from utils.msl import get_su_platform_access_cookie_status, get_su_platform_organisations

if TYPE_CHECKING:
from collections.abc import Iterable, Mapping, Sequence
from collections.abc import Sequence
from collections.abc import Set as AbstractSet
from logging import Logger
from typing import Final
Expand All @@ -31,21 +30,6 @@

logger: "Final[Logger]" = logging.getLogger("TeX-Bot")

REQUEST_HEADERS: "Final[Mapping[str, str]]" = {
"Cache-Control": "no-cache",
"Pragma": "no-cache",
"Expires": "0",
}

REQUEST_COOKIES: "Final[Mapping[str, str]]" = {
".AspNet.SharedCookie": settings["SU_PLATFORM_ACCESS_COOKIE"]
}

SU_PLATFORM_PROFILE_URL: "Final[str]" = "https://guildofstudents.com/profile"
SU_PLATFORM_ORGANISATION_URL: "Final[str]" = (
"https://www.guildofstudents.com/organisation/admin"
)


class SUPlatformAccessCookieStatus(Enum):
"""Enum class defining the status of the SU Platform Access Cookie."""
Expand Down Expand Up @@ -73,114 +57,7 @@ class SUPlatformAccessCookieStatus(Enum):
)


class CheckSUPlatformAuthorisationBaseCog(TeXBotBaseCog):
"""Cog class that defines the base functionality for cookie authorisation checks."""

async def _fetch_url_content_with_session(self, url: str) -> str:
"""Fetch the HTTP content at the given URL, using a shared aiohttp session."""
async with (
aiohttp.ClientSession(
headers=REQUEST_HEADERS, cookies=REQUEST_COOKIES
) as http_session,
http_session.get(url=url, ssl=GLOBAL_SSL_CONTEXT) as http_response,
):
return await http_response.text()

async def get_su_platform_access_cookie_status(self) -> SUPlatformAccessCookieStatus:
"""Retrieve the current validity status of the SU platform access cookie."""
response_object: bs4.BeautifulSoup = bs4.BeautifulSoup(
await self._fetch_url_content_with_session(SU_PLATFORM_PROFILE_URL), "html.parser"
)
page_title: bs4.Tag | bs4.NavigableString | None = response_object.find("title")
if not page_title or "Login" in str(page_title):
logger.warning("Token is invalid or expired.")
return SUPlatformAccessCookieStatus.INVALID

organisation_admin_url: str = (
f"{SU_PLATFORM_ORGANISATION_URL}/{settings['ORGANISATION_ID']}"
)
response_html: str = await self._fetch_url_content_with_session(organisation_admin_url)

if "admin tools" in response_html.lower():
return SUPlatformAccessCookieStatus.AUTHORISED

if "You do not have any permissions for this organisation" in response_html.lower():
return SUPlatformAccessCookieStatus.VALID

logger.warning(
"Unexpected response when checking SU platform access cookie authorisation."
)
return SUPlatformAccessCookieStatus.INVALID

async def get_su_platform_organisations(self) -> "Iterable[str]":
"""Retrieve the MSL organisations the current SU platform cookie has access to."""
response_object: bs4.BeautifulSoup = bs4.BeautifulSoup(
await self._fetch_url_content_with_session(SU_PLATFORM_PROFILE_URL), "html.parser"
)

page_title: bs4.Tag | bs4.NavigableString | None = response_object.find("title")

if not page_title:
logger.warning(
"Profile page returned no content when checking "
"SU platform access cookie's authorisation."
)
return ()

if "Login" in str(page_title):
logger.warning(
"Authentication redirected to login page. "
"SU platform access cookie is invalid or expired."
)
return ()

profile_section_html: bs4.Tag | bs4.NavigableString | None = response_object.find(
"div", {"id": "profile_main"}
)

if profile_section_html is None:
logger.warning(
"Couldn't find the profile section of the user "
"when scraping the SU platform's website HTML."
)
logger.debug("Retrieved HTML: %s", response_object.text)
return ()

user_name: bs4.Tag | bs4.NavigableString | int | None = profile_section_html.find("h1")

if not isinstance(user_name, bs4.Tag):
logger.warning(
"Found user profile on the SU platform but couldn't find their name."
)
logger.debug("Retrieved HTML: %s", response_object.text)
return ()

parsed_html: bs4.Tag | bs4.NavigableString | None = response_object.find(
"ul", {"id": "ulOrgs"}
)

if parsed_html is None or isinstance(parsed_html, bs4.NavigableString):
NO_ADMIN_TABLE_MESSAGE: Final[str] = (
f"Failed to retrieve the admin table for user: {user_name.string}. "
"Please check you have used the correct SU platform access token!"
)
logger.warning(NO_ADMIN_TABLE_MESSAGE)
return ()

organisations: Iterable[str] = [
list_item.get_text(strip=True) for list_item in parsed_html.find_all("li")
]

logger.debug(
"SU platform access cookie has admin authorisation to: %s as user %s",
organisations,
user_name.text,
)

return organisations


class CheckSUPlatformAuthorisationCommandCog(CheckSUPlatformAuthorisationBaseCog):
class CheckSUPlatformAuthorisationCommandCog(TeXBotBaseCog):
"""Cog class that defines the "/check-su-platform-authorisation" command."""

@discord.slash_command(
Expand All @@ -201,7 +78,7 @@ async def check_su_platform_authorisation(self, ctx: "TeXBotApplicationContext")

async with ctx.typing():
su_platform_access_cookie_organisations: AbstractSet[str] = set(
await self.get_su_platform_organisations()
await get_su_platform_organisations()
)

await ctx.followup.send(
Expand All @@ -224,7 +101,7 @@ async def check_su_platform_authorisation(self, ctx: "TeXBotApplicationContext")
)


class CheckSUPlatformAuthorisationTaskCog(CheckSUPlatformAuthorisationBaseCog):
class CheckSUPlatformAuthorisationTaskCog(TeXBotBaseCog):
"""Cog class defining a repeated task for checking SU platform access cookie."""

@override
Expand Down Expand Up @@ -256,7 +133,7 @@ async def su_platform_access_cookie_check_task(self) -> None:
logger.debug("Running SU platform access cookie check task...")

su_platform_access_cookie_status: tuple[int, str] = (
await self.get_su_platform_access_cookie_status()
await get_su_platform_access_cookie_status()
).value

logger.log(
Expand Down
4 changes: 4 additions & 0 deletions utils/msl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from typing import TYPE_CHECKING

from .authorisation import get_su_platform_access_cookie_status, get_su_platform_organisations
from .memberships import (
fetch_community_group_members_count,
fetch_community_group_members_list,
Expand All @@ -12,7 +13,10 @@
from collections.abc import Sequence

__all__: "Sequence[str]" = (
"GLOBAL_SSL_CONTEXT",
"fetch_community_group_members_count",
"fetch_community_group_members_list",
"get_su_platform_access_cookie_status",
"get_su_platform_organisations",
"is_id_a_community_group_member",
)
135 changes: 135 additions & 0 deletions utils/msl/authorisation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Module for authorisation checks."""

import logging
from typing import TYPE_CHECKING

import aiohttp
import bs4

from cogs.check_su_platform_authorisation import SUPlatformAccessCookieStatus
from config import settings
from utils import GLOBAL_SSL_CONTEXT

from .core import BASE_COOKIES, BASE_HEADERS

if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from logging import Logger
from typing import Final


__all__: "Sequence[str]" = (
"get_su_platform_access_cookie_status",
"get_su_platform_organisations",
)


logger: "Final[Logger]" = logging.getLogger("TeX-Bot")


SU_PLATFORM_PROFILE_URL: "Final[str]" = "https://guildofstudents.com/profile"
SU_PLATFORM_ORGANISATION_URL: "Final[str]" = (
"https://www.guildofstudents.com/organisation/admin"
)


async def _fetch_url_content_with_session(url: str) -> str:
"""Fetch the HTTP content at the given URL, using a shared aiohttp session."""
async with (
aiohttp.ClientSession(headers=BASE_HEADERS, cookies=BASE_COOKIES) as http_session,
http_session.get(url=url, ssl=GLOBAL_SSL_CONTEXT) as http_response,
):
return await http_response.text()


async def get_su_platform_access_cookie_status() -> SUPlatformAccessCookieStatus:
"""Retrieve the current validity status of the SU platform access cookie."""
response_object: bs4.BeautifulSoup = bs4.BeautifulSoup(
await _fetch_url_content_with_session(SU_PLATFORM_PROFILE_URL), "html.parser"
)
page_title: bs4.Tag | bs4.NavigableString | None = response_object.find("title")
if not page_title or "Login" in str(page_title):
logger.debug("Token is invalid or expired.")
return SUPlatformAccessCookieStatus.INVALID

organisation_admin_url: str = (
f"{SU_PLATFORM_ORGANISATION_URL}/{settings['ORGANISATION_ID']}"
)
response_html: str = await _fetch_url_content_with_session(organisation_admin_url)

if "admin tools" in response_html.lower():
return SUPlatformAccessCookieStatus.AUTHORISED

if "You do not have any permissions for this organisation" in response_html.lower():
return SUPlatformAccessCookieStatus.VALID

logger.warning(
"Unexpected response when checking SU platform access cookie authorisation."
)
return SUPlatformAccessCookieStatus.INVALID


async def get_su_platform_organisations() -> "Iterable[str]":
"""Retrieve the MSL organisations the current SU platform cookie has access to."""
response_object: bs4.BeautifulSoup = bs4.BeautifulSoup(
await _fetch_url_content_with_session(SU_PLATFORM_PROFILE_URL), "html.parser"
)

page_title: bs4.Tag | bs4.NavigableString | None = response_object.find("title")

if not page_title:
logger.warning(
"Profile page returned no content when checking "
"SU platform access cookie's authorisation."
)
return ()

if "Login" in str(page_title):
logger.warning(
"Authentication redirected to login page. "
"SU platform access cookie is invalid or expired."
)
return ()

profile_section_html: bs4.Tag | bs4.NavigableString | None = response_object.find(
"div", {"id": "profile_main"}
)

if profile_section_html is None:
logger.warning(
"Couldn't find the profile section of the user "
"when scraping the SU platform's website HTML."
)
logger.debug("Retrieved HTML: %s", response_object.text)
return ()

user_name: bs4.Tag | bs4.NavigableString | int | None = profile_section_html.find("h1")

if not isinstance(user_name, bs4.Tag):
logger.warning("Found user profile on the SU platform but couldn't find their name.")
logger.debug("Retrieved HTML: %s", response_object.text)
return ()

parsed_html: bs4.Tag | bs4.NavigableString | None = response_object.find(
"ul", {"id": "ulOrgs"}
)

if parsed_html is None or isinstance(parsed_html, bs4.NavigableString):
NO_ADMIN_TABLE_MESSAGE: Final[str] = (
f"Failed to retrieve the admin table for user: {user_name.string}. "
"Please check you have used the correct SU platform access token!"
)
logger.warning(NO_ADMIN_TABLE_MESSAGE)
return ()

organisations: Iterable[str] = [
list_item.get_text(strip=True) for list_item in parsed_html.find_all("li")
]

logger.debug(
"SU platform access cookie has admin authorisation to: %s as user %s",
organisations,
user_name.text,
)

return organisations
Loading