Skip to content

Instantly share code, notes, and snippets.

@kwyntes
Created June 4, 2023 21:30
Show Gist options
  • Save kwyntes/454fc21fd39edcdbfc614a78510a215f to your computer and use it in GitHub Desktop.
Save kwyntes/454fc21fd39edcdbfc614a78510a215f to your computer and use it in GitHub Desktop.
pain [magister api grades extractor thing]
#!/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