Skip to content

Commit da721e6

Browse files
Implement automatic auth token checking (#482)
1 parent dedc7be commit da721e6

File tree

3 files changed

+274
-70
lines changed

3 files changed

+274
-70
lines changed

cogs/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
CommitteeHandoverCommandCog,
1515
)
1616
from .archive import ArchiveCommandCog
17-
from .check_su_platform_authorisation import CheckSUPlatformAuthorisationCommandCog
17+
from .check_su_platform_authorisation import (
18+
CheckSUPlatformAuthorisationCommandCog,
19+
CheckSUPlatformAuthorisationTaskCog,
20+
)
1821
from .command_error import CommandErrorCog
1922
from .committee_actions_tracking import (
2023
CommitteeActionsTrackingContextCommandsCog,
@@ -54,6 +57,7 @@
5457
"AnnualYearChannelsIncrementCommandCog",
5558
"ArchiveCommandCog",
5659
"CheckSUPlatformAuthorisationCommandCog",
60+
"CheckSUPlatformAuthorisationTaskCog",
5761
"ClearRemindersBacklogTaskCog",
5862
"CommandErrorCog",
5963
"CommitteeActionsTrackingContextCommandsCog",
@@ -123,6 +127,7 @@ def setup(bot: "TeXBot") -> None:
123127
StatsCommandsCog,
124128
StrikeCommandCog,
125129
StrikeContextCommandsCog,
130+
CheckSUPlatformAuthorisationTaskCog,
126131
WriteRolesCommandCog,
127132
)
128133
Cog: type[TeXBotBaseCog]
Lines changed: 191 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
11
"""Contains cog classes for SU platform access cookie authorisation check interactions."""
22

33
import logging
4-
from typing import TYPE_CHECKING
4+
from enum import Enum
5+
from typing import TYPE_CHECKING, override
56

67
import aiohttp
78
import bs4
89
import discord
9-
from bs4 import BeautifulSoup
10+
from discord.ext import tasks
1011

1112
from config import settings
1213
from utils import CommandChecks, TeXBotBaseCog
14+
from utils.error_capture_decorators import (
15+
capture_guild_does_not_exist_error,
16+
)
1317

1418
if TYPE_CHECKING:
15-
from collections.abc import Collection, Mapping, Sequence
19+
from collections.abc import Iterable, Mapping, Sequence
20+
from collections.abc import Set as AbstractSet
1621
from logging import Logger
1722
from typing import Final
1823

19-
from utils import TeXBotApplicationContext
24+
from utils import TeXBot, TeXBotApplicationContext
2025

21-
__all__: "Sequence[str]" = ("CheckSUPlatformAuthorisationCommandCog",)
26+
__all__: "Sequence[str]" = (
27+
"CheckSUPlatformAuthorisationCommandCog",
28+
"CheckSUPlatformAuthorisationTaskCog",
29+
)
2230

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

@@ -29,55 +37,101 @@
2937
}
3038

3139
REQUEST_COOKIES: "Final[Mapping[str, str]]" = {
32-
".ASPXAUTH": settings["SU_PLATFORM_ACCESS_COOKIE"],
40+
".ASPXAUTH": settings["SU_PLATFORM_ACCESS_COOKIE"]
3341
}
3442

35-
REQUEST_URL: "Final[str]" = "https://guildofstudents.com/profile"
43+
SU_PLATFORM_PROFILE_URL: "Final[str]" = "https://guildofstudents.com/profile"
44+
SU_PLATFORM_ORGANISATION_URL: "Final[str]" = (
45+
"https://www.guildofstudents.com/organisation/admin"
46+
)
3647

3748

38-
class CheckSUPlatformAuthorisationCommandCog(TeXBotBaseCog):
39-
"""Cog class that defines the "/check-su-platform-authorisation-cookie" command."""
49+
class SUPlatformAccessCookieStatus(Enum):
50+
"""Enum class defining the status of the SU Platform Access Cookie."""
4051

41-
@discord.slash_command( # type: ignore[no-untyped-call, misc]
42-
name="check-su-platform-authorisation",
43-
description="Checks the authorisations held by the SU access token.",
52+
INVALID = (
53+
logging.WARNING,
54+
(
55+
"The SU platform access cookie is not associated with any MSL user, "
56+
"meaning it is invalid or expired."
57+
),
58+
)
59+
VALID = (
60+
logging.WARNING,
61+
(
62+
"The SU platform access cookie is associated with a valid MSL user, "
63+
"but is not an admin to any MSL organisations."
64+
),
65+
)
66+
AUTHORISED = (
67+
logging.INFO,
68+
(
69+
"The SU platform access cookie is associated with a valid MSL user and "
70+
"has access to at least one MSL organisation."
71+
),
4472
)
45-
@CommandChecks.check_interaction_user_has_committee_role
46-
@CommandChecks.check_interaction_user_in_main_guild
47-
async def check_su_platform_authorisation(self, ctx: "TeXBotApplicationContext") -> None: # type: ignore[misc]
48-
"""
49-
Definition of the "check_su_platform_authorisation" command.
5073

51-
The "check_su_platform_authorisation" command will retrieve the profile for the user.
52-
The profile page will contain the user's name and a list of the MSL organisations
53-
the user has administrative access to.
54-
"""
55-
http_session: aiohttp.ClientSession = aiohttp.ClientSession(
56-
headers=REQUEST_HEADERS, cookies=REQUEST_COOKIES
74+
75+
class CheckSUPlatformAuthorisationBaseCog(TeXBotBaseCog):
76+
"""Cog class that defines the base functionality for cookie authorisation checks."""
77+
78+
async def _fetch_url_content_with_session(self, url: str) -> str:
79+
"""Fetch the HTTP content at the given URL, using a shared aiohttp session."""
80+
async with (
81+
aiohttp.ClientSession(
82+
headers=REQUEST_HEADERS, cookies=REQUEST_COOKIES
83+
) as http_session,
84+
http_session.get(url) as http_response,
85+
):
86+
return await http_response.text()
87+
88+
async def get_su_platform_access_cookie_status(self) -> SUPlatformAccessCookieStatus:
89+
"""Retrieve the current validity status of the SU platform access cookie."""
90+
response_object: bs4.BeautifulSoup = bs4.BeautifulSoup(
91+
await self._fetch_url_content_with_session(SU_PLATFORM_PROFILE_URL), "html.parser"
92+
)
93+
page_title: bs4.Tag | bs4.NavigableString | None = response_object.find("title")
94+
if not page_title or "Login" in str(page_title):
95+
logger.debug("Token is invalid or expired.")
96+
return SUPlatformAccessCookieStatus.INVALID
97+
98+
organisation_admin_url: str = (
99+
f"{SU_PLATFORM_ORGANISATION_URL}/{settings['ORGANISATION_ID']}"
57100
)
101+
response_html: str = await self._fetch_url_content_with_session(organisation_admin_url)
58102

59-
async with http_session, http_session.get(REQUEST_URL) as http_response:
60-
response_html: str = await http_response.text()
103+
if "admin tools" in response_html.lower():
104+
return SUPlatformAccessCookieStatus.AUTHORISED
61105

62-
response_object: bs4.BeautifulSoup = BeautifulSoup(response_html, "html.parser")
106+
if "You do not have any permissions for this organisation" in response_html.lower():
107+
return SUPlatformAccessCookieStatus.VALID
108+
109+
logger.warning(
110+
"Unexpected response when checking SU platform access cookie authorisation."
111+
)
112+
return SUPlatformAccessCookieStatus.INVALID
113+
114+
async def get_su_platform_organisations(self) -> "Iterable[str]":
115+
"""Retrieve the MSL organisations the current SU platform cookie has access to."""
116+
response_object: bs4.BeautifulSoup = bs4.BeautifulSoup(
117+
await self._fetch_url_content_with_session(SU_PLATFORM_PROFILE_URL), "html.parser"
118+
)
63119

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

66122
if not page_title:
67-
await self.command_send_error(
68-
ctx=ctx,
69-
message="Profile page returned no content when checking token authorisation!",
123+
logger.warning(
124+
"Profile page returned no content when checking "
125+
"SU platform access cookie's authorisation."
70126
)
71-
return
127+
return ()
72128

73129
if "Login" in str(page_title):
74-
INVALID_COOKIE_MESSAGE: Final[str] = (
75-
"Unable to fetch profile page because "
76-
"the SU platform access cookie was not valid."
130+
logger.warning(
131+
"Authentication redirected to login page. "
132+
"SU platform access cookie is invalid or expired."
77133
)
78-
logger.warning(INVALID_COOKIE_MESSAGE)
79-
await ctx.respond(content=INVALID_COOKIE_MESSAGE)
80-
return
134+
return ()
81135

82136
profile_section_html: bs4.Tag | bs4.NavigableString | None = response_object.find(
83137
"div", {"id": "profile_main"}
@@ -86,24 +140,19 @@ async def check_su_platform_authorisation(self, ctx: "TeXBotApplicationContext")
86140
if profile_section_html is None:
87141
logger.warning(
88142
"Couldn't find the profile section of the user "
89-
"when scraping the website's HTML!"
90-
)
91-
logger.debug("Retrieved HTML: %s", response_html)
92-
await ctx.respond(
93-
"Couldn't find the profile of the user! "
94-
"This should never happen, please check the logs!"
143+
"when scraping the SU platform's website HTML."
95144
)
96-
return
145+
logger.debug("Retrieved HTML: %s", response_object.text)
146+
return ()
97147

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

100150
if not isinstance(user_name, bs4.Tag):
101-
NO_PROFILE_DEBUG_MESSAGE: Final[str] = (
102-
"Found user profile but couldn't find their name!"
151+
logger.warning(
152+
"Found user profile on the SU platform but couldn't find their name."
103153
)
104-
logger.debug(NO_PROFILE_DEBUG_MESSAGE)
105-
await ctx.respond(NO_PROFILE_DEBUG_MESSAGE)
106-
return
154+
logger.debug("Retrieved HTML: %s", response_object.text)
155+
return ()
107156

108157
parsed_html: bs4.Tag | bs4.NavigableString | None = response_object.find(
109158
"ul", {"id": "ulOrgs"}
@@ -112,36 +161,109 @@ async def check_su_platform_authorisation(self, ctx: "TeXBotApplicationContext")
112161
if parsed_html is None or isinstance(parsed_html, bs4.NavigableString):
113162
NO_ADMIN_TABLE_MESSAGE: Final[str] = (
114163
f"Failed to retrieve the admin table for user: {user_name.string}. "
115-
"Please check you have used the correct SU platform access cookie!"
164+
"Please check you have used the correct SU platform access token!"
116165
)
117166
logger.warning(NO_ADMIN_TABLE_MESSAGE)
118-
await ctx.respond(content=NO_ADMIN_TABLE_MESSAGE)
119-
return
167+
return ()
120168

121-
organisations: Collection[str] = [
169+
organisations: Iterable[str] = [
122170
list_item.get_text(strip=True) for list_item in parsed_html.find_all("li")
123171
]
124172

125-
if not organisations:
126-
logger.warning(
127-
(
128-
"Organisations list was unexpectedly empty "
129-
"for the SU platform access cookie associated with %s."
130-
),
131-
user_name.text,
132-
)
133-
await ctx.respond(content="Unexpectedly empty organisations error.")
134-
return
135-
136173
logger.debug(
137-
"The SU platform access cookie has administrator access to: %s as user %s",
174+
"SU platform access cookie has admin authorisation to: %s as user %s",
138175
organisations,
139176
user_name.text,
140177
)
141178

142-
await ctx.respond(
143-
"The SU platform access cookie has administrator access to "
144-
"the following MSL Organisations as "
145-
f"{user_name.text}:\n{',\n'.join(organisation for organisation in organisations)}",
146-
ephemeral=True,
179+
return organisations
180+
181+
182+
class CheckSUPlatformAuthorisationCommandCog(CheckSUPlatformAuthorisationBaseCog):
183+
"""Cog class that defines the "/check-su-platform-authorisation" command."""
184+
185+
@discord.slash_command( # type: ignore[no-untyped-call, misc]
186+
name="check-su-platform-authorisation",
187+
description="Checks the authorisation held by the SU platform access cookie.",
188+
)
189+
@CommandChecks.check_interaction_user_has_committee_role
190+
@CommandChecks.check_interaction_user_in_main_guild
191+
async def check_su_platform_authorisation(self, ctx: "TeXBotApplicationContext") -> None: # type: ignore[misc]
192+
"""
193+
Definition of the "check_su_platform_authorisation" command.
194+
195+
The "check_su_platform_authorisation" command will retrieve the profile for the user.
196+
The profile page will contain the user's name and a list of the MSL organisations
197+
the user has administrative access to.
198+
"""
199+
await ctx.defer(ephemeral=True)
200+
201+
async with ctx.typing():
202+
su_platform_access_cookie_organisations: AbstractSet[str] = set(
203+
await self.get_su_platform_organisations()
204+
)
205+
206+
await ctx.followup.send(
207+
content=(
208+
"No MSL organisations are available to the SU platform access cookie. "
209+
"Please check the logs for errors."
210+
if not su_platform_access_cookie_organisations
211+
else (
212+
f"SU Platform Access Cookie has access to the following "
213+
"MSL Organisations:"
214+
f"\n{
215+
',\n'.join(
216+
organisation
217+
for organisation in su_platform_access_cookie_organisations
218+
)
219+
}"
220+
)
221+
),
222+
ephemeral=True,
223+
)
224+
225+
226+
class CheckSUPlatformAuthorisationTaskCog(CheckSUPlatformAuthorisationBaseCog):
227+
"""Cog class defining a repeated task for checking SU platform access cookie."""
228+
229+
@override
230+
def __init__(self, bot: "TeXBot") -> None:
231+
"""Start all task managers when this cog is initialised."""
232+
if settings["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"]:
233+
_ = self.su_platform_access_cookie_check_task.start()
234+
235+
super().__init__(bot)
236+
237+
@override
238+
def cog_unload(self) -> None:
239+
"""
240+
Unload-hook that ends all running tasks whenever the tasks cog is unloaded.
241+
242+
This may be run dynamically or when the bot closes.
243+
"""
244+
self.su_platform_access_cookie_check_task.cancel()
245+
246+
@tasks.loop(**settings["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL"])
247+
@capture_guild_does_not_exist_error
248+
async def su_platform_access_cookie_check_task(self) -> None:
249+
"""
250+
Definition of the repeated background task that checks the SU platform access cookie.
251+
252+
The task will check if the cookie is valid and if it is, it will retrieve the
253+
MSL organisations the cookie has access to.
254+
"""
255+
logger.debug("Running SU platform access cookie check task...")
256+
257+
su_platform_access_cookie_status: tuple[int, str] = (
258+
await self.get_su_platform_access_cookie_status()
259+
).value
260+
261+
logger.log(
262+
level=su_platform_access_cookie_status[0],
263+
msg=su_platform_access_cookie_status[1],
147264
)
265+
266+
@su_platform_access_cookie_check_task.before_loop
267+
async def before_tasks(self) -> None:
268+
"""Pre-execution hook, preventing any tasks from executing before the bot is ready."""
269+
await self.bot.wait_until_ready()

0 commit comments

Comments
 (0)