Created
June 4, 2023 21:30
-
-
Save kwyntes/454fc21fd39edcdbfc614a78510a215f to your computer and use it in GitHub Desktop.
pain [magister api grades extractor thing]
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# i don't know why i ever wrote this (i definitely regret writing it) | |
# but i found it on my harddrive and thought it might help alleviate some | |
# of the pain for anyone who ever decides they need something like this for whatever reason... | |
# i remember i had some kind of idea with this but right now it just outputs all grades info | |
# it extracts to a grades.json file. | |
# this is actually really useful for when your school disables viewing grades in test weeks | |
# *** looking at you stanislas >:( *** | |
### [Config] | |
MAGISTER_HOSTNAME = '__SCHOOL__.magister.net' # so like 'stanislas.magister.net' or whatever | |
USERNAME = '__USERNAME__' # leerlingnummer of wat dan ook | |
PASSWORD = '__PASSWORD__' | |
### | |
from colorama import just_fix_windows_console, Fore, Style | |
from tabulate import tabulate | |
from urllib.parse import parse_qs, quote, urlencode, urlparse | |
import json, requests | |
just_fix_windows_console() | |
def green(s): return Fore.GREEN + s + Style.RESET_ALL | |
def blue(s): return Fore.BLUE + s + Style.RESET_ALL | |
def red(s): return Fore.RED + s + Style.RESET_ALL | |
def yellow(s): return Fore.LIGHTYELLOW_EX + s + Style.RESET_ALL | |
USERNAME_URL = 'https://accounts.magister.net/challenges/username' | |
PASSWORD_URL = 'https://accounts.magister.net/challenges/password' | |
# Truly fabulous error handling. | |
def fatal(msg, where): | |
print(red('FATAL'), yellow(f'[{where}]') if where else '', msg) | |
exit() | |
def construct_auth_url(magister_hostname): | |
# From https://*.magister.net/oidc_config.js | |
# | |
# var config = { | |
# authority: 'https://accounts.magister.net', | |
# client_id: 'M6-' + window.location.hostname, | |
# redirect_uri: 'https://' + window.location.hostname + '/oidc/redirect_callback.html', | |
# response_type: 'id_token token', | |
# scope: 'openid profile opp.read opp.manage attendance.overview calendar.ical.user calendar.to-do.user', | |
# post_logout_redirect_uri: 'https://' + window.location.hostname + '/oidc/afterlogoff.html', | |
# silent_redirect_uri: 'https://' + window.location.hostname + '/oidc/silent.html', | |
# acr_values: 'tenant:' + window.location.hostname, | |
# monitorSession: false | |
# }; | |
# Note: not all parameters from this object are used here, and I have yet to figure out where `nonce` comes from. | |
# it doesn't seem to chance however, so it might not really matter. | |
# By using my browser's devtools I found they submit a `state` and an `acr_values` parameter as well, | |
# but these don't seem to be required interestingly enough. | |
return 'https://accounts.magister.net/connect/authorize/callback?' + urlencode({ | |
'client_id': 'M6-' + magister_hostname, | |
'redirect_uri': 'https://' + magister_hostname + '/oidc/redirect_callback.html', | |
'response_type': 'id_token token', | |
'scope': 'openid profile opp.read opp.manage attendance.overview calendar.ical.user calendar.to-do.user', | |
'nonce': 'f1b554d130244e4c8640b58346fbd663' # magic value | |
}, quote_via=quote) | |
def get_querystring_param(url, param): | |
return parse_qs(urlparse(url).query)[param][0] | |
def login(magister_hostname, username, password): | |
# Fuck you magister people for making the worst possible authentication system that could ever exist. | |
# Like seriously, what are we even doing here?? | |
auth_url = construct_auth_url(magister_hostname) | |
s = requests.Session() | |
# Redirect to the actual login page | |
redir1 = s.get(auth_url, allow_redirects=False) | |
if redir1.status_code != 302: | |
# Imagine this ever happening haha (pls don't) | |
fatal(f'Expected status code 302 but instead got {redir1.status_code}', 'redir1') | |
# Except oh no, that wasn't the actual login page, redirect again! | |
redir2 = s.get(redir1.headers['Location'], allow_redirects=False) | |
if redir2.status_code != 302: | |
fatal(f'Expected status code 302 but instead got {redir2.status_code}', 'redir2') | |
# It actually tries to redirect us again... these people are insane | |
# We will not surrender to them! All hail allow_redirects=False | |
# Now extract the session ID from the querystring of the URL it's trying to send us to | |
# (god this is so cursed) | |
session_id = get_querystring_param(redir2.headers['Location'], 'sessionId') | |
# Also take a XSRF token from the cookies because we will need to send that as a header | |
# (*then why the fuck do you put it in cookies???*) | |
xsrf = redir2.cookies['XSRF-TOKEN'] | |
# Now instead of simply having a single login endpoint, they decided it was a good idea to send the username | |
# and password seperately. In all fairness, this kind of makes sense if you look at their login UI. | |
# It doesn't make me despise you any less. | |
# Note that here, apart from the username and thereafter the password, I am only sending them the session ID. | |
# For the website they also send a returnURL parameter that seems to be completely ignored by the server, | |
# and a mysterious authCode parameter, whose value is derived from this idiotically obfuscated piece of code | |
# that lives somewhere in https://accounts.magister.net/js/account-3ef0e3a9de57c517538b.js. | |
# | |
# ((o = ["b27570bc", "191130adb9", "b16d", "ceed"]), | |
# ["1", "1"] | |
# .map(function (t) { | |
# return o[parseInt(t) || 0]; | |
# }) | |
# .join("")) | |
# | |
# It takes the second element in the `o` array and repeats it twice (or once, depending on your interpretation of 'repeat'). | |
# | |
# What the actual fuck. | |
# Why? | |
# Just, why? | |
# Who wrote this shit? | |
# Even if it is the closure compiler to blame for this weird obfuscation (which it *hopefully* is), still, what the fuck? | |
# It's not even used (or well at least not required to be sent)! | |
# And even if it was, what's it supposed to do? It's a static value (unless they recompile their whole JS bundle every hour | |
# or something maybe - you can expect anything at this point), and it's the same for every user so it can't offer any security. | |
# | |
# I digress. We still have work to do. Places to be. I didn't want to write any of this. I just wanted a simple overview of | |
# my grades. And now we're in this fucking mess. You didn't have to do this to me Magister. It didn't have to be this way. | |
# We could've both been happy. | |
# Submit username | |
ch_user = s.post(USERNAME_URL, headers={'X-XSRF-TOKEN': xsrf}, json={ | |
'sessionId': session_id, | |
'username': username | |
}) | |
if ch_user.status_code != 200: | |
fatal(f'Expected status code 200 but instead got {ch_user.status_code}', 'ch_user') | |
# Submit password | |
ch_pass = s.post(PASSWORD_URL, headers={'X-XSRF-TOKEN': xsrf}, json={ | |
'sessionId': session_id, | |
'password': password | |
}) | |
if ch_pass.status_code != 200: | |
fatal(f'Expected status code 200 but instead got {ch_pass.status_code}', 'ch_pass') | |
# Now request the original URL again, which (if everything went right) will then try to send us | |
# to a different page that then loads the final access_token we're looking for from a hash parameter. | |
# Good thing I have outstanding self-control. | |
final = s.get(auth_url, allow_redirects=False) | |
if final.status_code != 302: | |
fatal(f'Expected status code 302 but instead got {final.status_code}', 'login_final') | |
# FINALLY (yes everything is awful and I have gained several mental health issues by now but THANK GOD IT IS OVER) | |
access_token = parse_qs(final.headers['Location'].partition('#')[2])['access_token'][0] | |
return {'Authorization': 'Bearer ' + access_token} | |
def get_account_id(magister_hostname, auth_header): | |
acc = requests.get(f'https://{magister_hostname}/api/account', headers=auth_header) | |
if acc.status_code != 200: | |
fatal(f'Expected status code 200 but instead got {acc.status_code}', 'acc_id') | |
return acc.json()['Persoon']['Id'] | |
def get_entry_id(magister_hostname, auth_header, account_id): | |
ent = requests.get(f'https://{magister_hostname}/api/leerlingen/{account_id}/aanmeldingen', headers=auth_header) | |
if ent.status_code != 200: | |
fatal(f'Expected status code 200 but instead got {ent.status_code}', 'ent_id') | |
# jaarlaag & leerjaarperiode zitten ook hierin, misschien leuk | |
return ent.json()['items'][0]['id'] | |
def get_grades(magister_hostname, auth_header, account_id, entry_id): | |
grds = requests.get(f'https://{magister_hostname}/api/personen/{account_id}/aanmeldingen/{entry_id}/cijfers/cijferoverzichtvooraanmelding?alleenPTAKolommen=true', headers=auth_header) | |
if grds.status_code != 200: | |
fatal(f'Expected status code 200 but instead got {grds.status_code}', 'get_grades') | |
return grds.json() | |
def get_column_info(magister_hostname, auth_header): | |
# | |
pass | |
if __name__ == '__main__': | |
auth_header = login(MAGISTER_HOSTNAME, USERNAME, PASSWORD) | |
account_id = get_account_id(MAGISTER_HOSTNAME, auth_header) | |
entry_id = get_entry_id(MAGISTER_HOSTNAME, auth_header, account_id) | |
grades = get_grades(MAGISTER_HOSTNAME, auth_header, account_id, entry_id) | |
# < ... or do something with `grades` here if you want ... > | |
with open('grades.json', 'w') as f: | |
f.write(json.dumps(grades, indent=2)) | |
# | |
# Command line idea: | |
# | |
# ./cijfers.py --> outputs grades table | |
# | |
# ./cijfers.py +netl:4*9 --> recalculates grade for [netl] with a [4] that weighs [9] | |
# |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment