Opdateret program til at hent kurser på dine værdipapirer hos Nordnet

Nordnet har opdateret deres loginprocedure, så her er et dugfrist program til at hente kurser hos Nordnet – eller Morningstar, hvis Nordnet skulle fejle:

# -*- coding: utf-8 -*-
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com
""" This program extracts historical stock prices from Nordnet (and Morningstar as a fallback) """

import requests
from datetime import datetime
from datetime import date

# Nordnet user account credentials
user = ''
password = ''

# DATE AND STOCK DATA. SHOULD BE EDITED FOR YOUR NEEDS #

# Start date (start of historical price period)
startdate = '2013-01-01'

# List of shares to look up prices for.
# Format is: Name, Morningstar id, Nordnet stock identifier
# See e.g. https://www.nordnet.dk/markedet/aktiekurser/16256554-novo-nordisk-b
# (identifier is 16256554)
# All shares must have a name (whatever you like). To get prices they must
# either have a Nordnet identifier or a Morningstar id
sharelist = [
	["Maj Invest Danske Obligationer","F0GBR064UX",16099874],
	["Novo Nordisk B A/S","0P0000A5BQ",16256554],
]

# A variable to store historical prices before saving to csv	
finalresult = ""
finalresult += '"date";"price";"instrument"' + '\n'

# LOGIN TO NORDNET #

session = requests.Session()

# Setting cookies prior to login by visiting login page
url = 'https://www.nordnet.dk/logind'
request = session.get(url)

# Update headers for login
session.headers['client-id'] = 'NEXT'
session.headers['sub-client-id'] = 'NEXT'

# Actual login
url = 'https://www.nordnet.dk/api/2/authentication/basic/login'
request = session.post(url, data = {'username': user, 'password': password})

# LOOPS TO REQUEST HISTORICAL PRICES AT NORDNET AND MORNINGSTAR #

# Nordnet loop to get historical prices
nordnet_fail = []

for share in sharelist:
	# Nordnet stock identifier and market number must both exist
	if share[2]:
		url = "https://www.nordnet.dk/api/2/instruments/historical/prices/" + str(share[2])
		payload = {"from": startdate, "fields": "last"}
		data = session.get(url, params=payload)
		jsondecode = data.json()
		
		# Sometimes the final date is returned twice. A list is created to check for duplicates.
		datelist = []
		if jsondecode[0]['prices']:
			try:
				for value in jsondecode[0]['prices']:
					if 'last' in value:
						price = str(value['last'])
					elif 'close_nav' in value:
						price = str(value['close_nav'])
					price = price.replace(".",",")
					date = datetime.fromtimestamp(value['time'] / 1000)
					date = datetime.strftime(date, '%Y-%m-%d')
					# Only adds a date if it has not been added before
					if date not in datelist:
						datelist.append(date)
						finalresult += '"' + date + '"' + ";" + '"' + price + '"' + ";" + '"' + share[0] + '"' + "\n"
			except Exception as error:
				print(error)
				breakpoint()
		# No price data returned! Try another method!
		else:
			nordnet_fail.append(share)

if nordnet_fail:
	print(nordnet_fail)
	# Morningstar loop to get historical prices			
	for share in nordnet_fail:
		# Only runs for one specific fund in this instance
		payload = {"id": share[1], "currencyId": "DKK", "idtype": "Morningstar", "frequency": "daily", "startDate": startdate, "outputType": "COMPACTJSON"}
		data = requests.get("http://tools.morningstar.dk/api/rest.svc/timeseries_price/nen6ere626", params=payload)
		jsondecode = data.json()
		
		for lists in jsondecode:
			price = str(lists[1])
			price = price.replace(".",",")
			date = datetime.fromtimestamp(lists[0] / 1000)
			date = datetime.strftime(date, '%Y-%m-%d')
			finalresult += '"' + date + '"' + ";" + '"' + price + '"' + ";" + '"' + share[0] + '"' + "\n"
		
# WRITE CSV OUTPUT TO FILE #			

with open("kurser.csv", "w", newline='', encoding='utf8') as fout:
	fout.write(finalresult)

Opdateret program til at hente transaktioner hos Nordnet

Nordnet har opdateret deres login-procedure, så jeg har også opdateret mit Python-script til at logge ind på Nordnet og hente transaktioner. Her er den nye version.

# -*- coding: utf-8 -*-
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com
""" This program logs into a Nordnet account and extracts transactions as a csv file.
Handy for exporting to Excel with as few manual steps as possible """

import requests 
from datetime import datetime
from datetime import date

# USER ACCOUNT, PORTFOLIO AND PERIOD DATA. SHOULD BE EDITED FOR YOUR NEEDS #

# Nordnet user account credentials and accounts/portfolios names (choose yourself) and numbers.
# To get account numbers go to https://www.nordnet.dk/transaktioner and change
# between accounts. The number after "accid=" in the new URL is your account number.
# If you have only one account, your account number is 1.
user = ''
password = ''
accounts = {
	'Nordnet: Frie midler': '1',
	'Nordnet: Ratepension': '3',
}

# Start date (start of period for transactions) and date today used for extraction of transactions
startdate = '2013-01-01'
today = date.today()
enddate = datetime.strftime(today, '%Y-%m-%d')

# Manual data lines. These can be used if you have portfolios elsewhere that you would
# like to add manually to the data set. If no manual data the variable manualdataexists
# should be set to False
manualdataexists = True
manualdata = '''
Id;Bogføringsdag;Handelsdag;Valørdag;Depot;Transaktionstype;Værdipapirer;Værdipapirtype;ISIN;Antal;Kurs;Rente;Samlede afgifter;Samlede afgifter Valuta ;Beløb;Valuta;Indkøbsværdi;Resultat;Totalt antal;Saldo;Vekslingskurs;Transaktionstekst;Makuleringsdato;Notanummer;Verifikationsnummer;Kurtage;Kurtage Valuta;Depotnavn
;30-09-2013;30-09-2013;30-09-2013;;KØBT;Obligationer 3,5%;Obligationer;;72000;;;;;-69.891,54;DKK;;;;;;;;;;;;Frie midler: Finansbanken
'''

# A variable to store transactions before saving to csv
transactions = ''

# LOGIN TO NORDNET #
session = requests.Session()

# Setting cookies prior to login by visiting login page
url = 'https://www.nordnet.dk/logind'
request = session.get(url)

# Update headers for login
session.headers['client-id'] = 'NEXT'
session.headers['sub-client-id'] = 'NEXT'

# Actual login
url = 'https://www.nordnet.dk/api/2/authentication/basic/login'
request = session.post(url, data = {'username': user, 'password': password})


# GET ACCOUNT(S) TRANSACTION DATA #

# Payload and url for transaction requests
payload = {
	'locale': 'da-DK',
	'from': startdate,
	'to': enddate,
}

url = 'https://www.nordnet.dk/mediaapi/transaction/csv/filtered'

firstaccount = True
for portfolioname, id in accounts.items():
	payload['account_id'] = id
	data = session.get(url, params=payload)
	result = data.content.decode('utf-16')
	result = result.replace('\t',';')
	result = result.splitlines()
	
	firstline = True
	for line in result:
		# For first account and first line, we use headers and add an additional column
		if line and firstline == True and firstaccount == True:
			transactions += line + ';' + 'Depotnavn' + '\n'
			firstaccount = False
			firstline = False
		# First lines of additional accounts are discarded
		elif line and firstline == True and firstaccount == False:
			firstline = False
		# Content lines are added
		elif line and firstline == False:
			# Fix because Nordnet sometimes adds one empty column too many
			if line.count(';') == 27:
				line = line.replace('; ',' ')
			transactions += line + ';' + portfolioname + '\n'

# ADD MANUAL LINES IF ANY #
if manualdataexists == True:
	manualdata = manualdata.split("\n",2)[2]
	transactions += manualdata				

# WRITE CSV OUTPUT TO FILE #
with open("transactions.csv", "w", encoding='utf8') as fout:
	fout.write(transactions)

Beskyttet: Sommerferie 2021

Dette indhold er beskyttet med adgangskode. Angiv adgangskode, for at se indholdet.

Udgivet
Kategoriseret som fotos

En lille kodeforbedring på Wallnot

I takt med at wallnot.dk har fået flere og flere funktionaliteter, og dermed også flere databasefelter, er koden bag blevet rodet.

Her er et eksempel på, hvordan min kode blev svær for mig selv at forstå, og hvad jeg gjorde for at gøre den lidt bedre.

For at hente artikler til Wallnot, besøger en robot avisers hjemmesider og finder gratis-artikler. Logikken er lidt forskellig fra medie til medie, for det er forskelligt, hvordan medierne afslører, om en artikel er bag en betalingsmur eller ej.

Det er også meget forskelligt, om aviserne benytter sig af “gratis artikler, der kræver login” (Politiken, Ingeniøren, Jyllands-Posten), og det er forskelligt, hvad risikoen er for dubletter (Ritzau-artikler går igen mange steder, men ikke alle. Jyllands-Posten og Finans kopierer artikler til og fra hinanden.)

Her er for eksempel den gamle logik for Danmarks Radio:

def dr():
	# Define medium
	medium = "dr"
	
	# Request site
	data = requests.get("https://www.dr.dk/nyheder/service/feeds/allenyheder/")
	result = data.text
	
	# Soup site and create a list of links and their titles
	soup = BeautifulSoup(result, "xml")
	
	# List of unique urls
	urllist = {link.text for link in soup.find_all('link') if "/nyheder/" in link.text and not any(term in link.text for term in excluded) and not any(term == link.text for term in mustnotbe)}
	
	# Loop that requests all article links, soups them and checks whether they have a paywall and generates a list with current free articles. Also gets titles of links.	
	for url in urllist:
		if not 'http' in url:
			url = 'https://dr.dk' + url	
		if url not in lastbatch and url not in newbatch:
			try:
				data = requests.get(url)
				result = data.text
				soup = BeautifulSoup(result, "lxml")
				node_id = soup.find('meta', attrs={'name':'ensighten:urn'})
				if node_id:
					id = node_id['content'][node_id['content'].find("article:")+8:]
					urlid = "dr_" + id
					if urlid not in newbatch and urlid not in lastbatch:
						title = soup.find('meta', attrs={'property':'og:title'})
						title = title['content']
						title = title.strip(" ")						
						#api_url = "https://www.dr.dk/tjenester/urd/tms/urn:dr:drupal:article:" + id
						#api_request = requests.get(api_url)
						timestamp = soup.find('meta', attrs={'property':'article:published_time'})['content']
						dateofarticle = datetime.strptime(timestamp, '%Y-%m-%dT%H:%M:%S%z')
						if '<p class="dre-article-body__paragraph dre-variables">/ritzau/</p>' in result:
							ritzau = True
							body = soup.find('div', attrs={'class': 'dre-article-body'})
							firstparagraph = body.find('p').get_text()
							firstparagraph = firstparagraph.strip(" ")
						else:
							ritzau = False

						if ritzau == True:	
							article = (title, urlid, dateofarticle, medium, url, datetime.now(), ritzau, firstparagraph)
						else:	
							article = (title, urlid, dateofarticle, medium, url, datetime.now(), ritzau)

						insert_unique_id_article(connection, article)
						newbatch.append(url)
						newbatch.append(urlid)				
			except Exception as e:
				print(url)
				print(e)
		else:
			newbatch.append(url)

Hvert medie har altså sin egen funktion til at hente artikler, og som du måske kan læse af koden, kalder hver funktionen en anden funktion, som indsætter artikler i databasen:

insert_unique_id_article(connection, article)
newbatch.append(url)

Problemet

Funktionen insert_unique_id_article var mildest talt blevet rodet.

Først blev indsat nogle ekstra værdier til article for at understøtte, at jeg for et tidspunkt valgte at artikler, der senere får en paywall, ikke slettes fra databasen, men blot markeres som med paywall.

Så havde den en overordnet logik, der skilte artikler, der skal tjekkes for dubletter fra artikler, der ikke skal.

Og under denne logik nogle forskellige variationer for, hvor mange ekstra variable og hvilke værdier, disse variable har, der tilføjes til hver article. Og mange forskellige sql-sætninger i forgreningen til rent faktisk at tilføje hver artikel til databasen.

Her er den uoverskuelige kode:

def insert_unique_id_article(connection, article):
	with connection:
		with connection.cursor() as cur:
			try:
				article += (False, "")	# Not currently behind a paywall, no current archive url, 
				if not article[6] and not article[3] == "jyllandsposten" and not article[3] == "finansdk":	# Ritzau is false and not jyllandsposten and not finansdk
					if len(article) == 10:	# article has loginwall variable inserted already
						article += (False, ) # Not a duplicate
						sql = ''' INSERT INTO wall_article(title,unique_id,date,medium,url,created_at,ritzau,loginwall,paywall_detected,archive_url,duplicate)
						VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) '''
						cur.execute(sql, article)
					else:
						article += (False, False)	# Not behind loginwall, not a duplicate
						sql = ''' INSERT INTO wall_article(title,unique_id,date,medium,url,created_at,ritzau,paywall_detected,archive_url,loginwall,duplicate)
						VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) '''
						cur.execute(sql, article)
				else:	# Ritzau is true or jyllandsposten or finans
					sql = ''' SELECT * from wall_article WHERE title ILIKE %s OR excerpt ILIKE %s'''
					article_title = "%"+article[0]+"%"
					article_excerpt = "%"+article[7]+"%"
					cur.execute(sql, (article_title, article_excerpt))
					results = cur.fetchall()
					if results:
						duplicate = False
						for result in results:
							timestamp = result[3].astimezone(pytz.utc)
							# Time in minutes between suspected duplicates is calculated
							if article[2] < timestamp:
								difference_in_minutes = (timestamp-article[2]).seconds/60
							elif article[2] > timestamp:
								difference_in_minutes = (article[2]-timestamp).seconds/60
							elif article[2] == timestamp:
								difference_in_minutes = 0
							
							# Less than or 5 hours between, mark duplicate and insert as duplicate
							if difference_in_minutes <= 300:
								duplicate = True				# important to avoid duplicate insert
								article += (True, False)		# a duplicate, no loginwall
								sql = ''' INSERT INTO wall_article(title,unique_id,date,medium,url,created_at,ritzau,excerpt,paywall_detected,archive_url,duplicate,loginwall)
								VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) '''
								cur.execute(sql, article)
								break
						# ONLY FOR NON-duplicates
						if not duplicate:
							article += (False, False) # Not a duplicate, no loginwall
							sql = ''' INSERT INTO wall_article(title,unique_id,date,medium,url,created_at,ritzau,excerpt,paywall_detected,archive_url,duplicate,loginwall)
								VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) '''
							cur.execute(sql, article)
					else:
						article += (False, False) # Not a duplicate, no loginwall
						sql = ''' INSERT INTO wall_article(title,unique_id,date,medium,url,created_at,ritzau,excerpt,paywall_detected,archive_url,duplicate,loginwall)
						VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) '''
						cur.execute(sql, article)
			except Error as e:
				# If unique_id is already in database and the article is a politiken share link,
				# the original URL is updated with the share url
				if e.pgcode == "23505" and "politiken.dk/del" in article[4]:
					connection.rollback()
					sql = ''' Update wall_article set url = %s, loginwall = %s where unique_id = %s '''
					cur.execute(sql, (article[4], True, article[1]))
				elif not e.pgcode == "23505":
					print(e, article)

Løsning

Jeg løste – eller måske formindskede – problemet ved at ensarte, hvor mange variable hvert medies funktion sender til funktionen insert_unique_id_article, sådan at medierne uanset om de fx bruger “login-mure” eller ej, sender en article af samme længde med samme variabelrækkefølge af sted til insert_unique_id_article.

Så nu går disse kodelinjer igen og er identiske, uanset hvilket medie, der er tale om:

article = (title, urlid, dateofarticle, medium, url, datetime.now(), ritzau, excerpt, loginwall)
insert_unique_id_article(connection, article)

Det har gjort insert_unique_id_article en del kortere og mere læsbar. Der er nu kun én mulig sql-sætning, færre mulige forgreninger og det er ensartet, hvor mange variable der tilføjes til article i hver forgrening:

def insert_unique_id_article(connection, article):
	with connection:
		with connection.cursor() as cur:
			try:
				sql = ''' INSERT INTO wall_article(title,unique_id,date,medium,url,created_at,ritzau,excerpt,loginwall,paywall_detected,archive_url,duplicate)
				VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) '''
				article += (False, "")	# Add defaults: Not currently behind a paywall, no current archive url, 
				if not article[6] and not article[3] == "jyllandsposten" and not article[3] == "finansdk":	# Ritzau is false and not jyllandsposten and not finansdk so no duplicate risk based on current newspaper practices
					article += (False, ) # Not a duplicate
					cur.execute(sql, article)
				else:	# Ritzau is true or jyllandsposten or finans, so run duplicate check
					search_sql = ''' SELECT * from wall_article WHERE title ILIKE %s OR excerpt ILIKE %s'''
					article_title = "%"+article[0]+"%"
					article_excerpt = "%"+article[7]+"%"
					cur.execute(search_sql, (article_title, article_excerpt))
					results = cur.fetchall()
					if results:
						duplicate = False
						for result in results:
							timestamp = result[3].astimezone(pytz.utc)
							# Time in minutes between suspected duplicates is calculated
							if article[2] < timestamp:
								difference_in_minutes = (timestamp-article[2]).seconds/60
							elif article[2] > timestamp:
								difference_in_minutes = (article[2]-timestamp).seconds/60
							elif article[2] == timestamp:
								difference_in_minutes = 0
							
							# Less than or 5 hours between, mark duplicate and insert as duplicate
							if difference_in_minutes <= 300:
								duplicate = True		# important to avoid duplicate insert
								break
						article += (duplicate, ) # Add duplicate status
						cur.execute(sql, article)
					else:
						article += (False, ) # Not a duplicate
						cur.execute(sql, article)
			except Error as e:
				# If unique_id is already in database and the article is a politiken share link,
				# the original URL is updated with the share url
				if e.pgcode == "23505" and "politiken.dk/del" in article[4]:
					connection.rollback()
					sql = ''' Update wall_article set url = %s, loginwall = %s where unique_id = %s '''
					cur.execute(sql, (article[4], True, article[1]))
				elif not e.pgcode == "23505":
					print(e, article)

En bedre løsning?

Min problematik handler rigtigt meget om vægtningen mellem graden af identisk kode, der går igen flere steder, og graden af abstraktion.

Det er rart og logisk for mig, at kunne arbejde med og rette fejl i hvert enkelt medie hver for sig. Men der er meget kode, der går igen for hvert medie, og også forskellig kode, der gør det samme, afhængig af hvornår jeg lige har haft fat i koden sidst.

Det er besværligt, at jeg, hvis jeg på et tidspunkt indfører en ny funktionalitet, der kræver et nyt databasefelt, hvis værdi kan være forskellig fra medie til medie, er nødt til at opdatere hvert enkelt medies funktion, for at sikre mig at længden på den artikel, der sendes til insert_unique_id_article er den samme for alle medier.

En løsning kunne være et lidt højere abstraktionsniveau, hvor jeg:

  • Laver article om til en ordbog (dictionary) i hvert medies funktion.
  • Sørger for at hvert medie sender article til en hjælpefunktion, der gør artiklen klar til at indsætte i databasen, ved at gennemgå ordbogens nøgler og tilføje de nøgler, der evt. mangler, for at artiklen har de nødvendige variable til at kunne indsættes. Funktionen kunne hedder prepare_article.
  • Sætter prepare_article til at sende artiklen til en endnu mere forenklet insert_unique_id_article.

En anden mulig løsning kunne være at kigge nærmere på modulet psycopg2, som er det modul, der lader mit Python-program tale med min database. Lige nu bruger jeg datatypen tuple når jeg indsætter data i databasen, og hvis der mangler værdier i min tuple i forhold til min datamodel for artikler, fungerer mit program ikke. Måske kan psycopg2 forstå dictionaries i stedet og fodres med standardværdier, der kan indsættes, hvis en artikel mangler felter fra datamodellen?

Det må jeg finde ud af, når jeg har tid, eller et problem der kræver, at jeg finder en bedre løsning.

Jeg fik Saxo til at lukke for adgang til ebøger for gratister

Jeg er både interesseret i at udforske API’er og i at læse bøger. Jeg har fx tidligere vist, hvordan jeg ved hjælp af en Android-emulator og programmet Charles fandt en løsning til at få videresendt E-boksbeskeder til min e-mail.

Og så skete der det, at jeg fik et prøveabonnement til Saxo Premium.

Derfor gav jeg mig i kast med at udforske boghandelen Saxos app til streaming af bøger og opdagede nogle sikkerhedsproblemer.

Jeg fandt ud af, at enhver med lidt teknisk snilde kunne skaffe sig adgang til Saxos ebøger, uden først at købe et streaming-abonnement.

Efter først at have kontaktet Saxo anonymt for at blive forsikret om, at de ikke ville melde mig til politiet, så længe jeg ikke havde udnyttet sikkerhedshullerne, fik jeg indrapporteret problemerne.

Saxo var både flinke og professionelle i dialogen, og nu har de lanceret en mere sikker app. Derfor vil og kan jeg nu fortælle om, hvordan jeg undersøgte Saxos app, og hvad jeg opdagede.

Om streaming og at sikre sig mod kopiering af data

Men først en sidebemærkning: Det er meget svært at give midlertidigt adgang til data (bøger, film, musik), som Saxo, Netflix, Spotify osv., gør, og være helt sikker på, at adgangen altid og i alle tilfælde kun er midlertidig.

Hvis noget kan ses på en skærm eller lyttes til på højttalere, skal der meget til at forhindre ihærdige brugere, (der måske endda er villige til at bryde ophavsretsloven – gisp!), i at få gemt en kopi af materialet.

Spørgsmålet er mere, hvor svært og tidskrævende man gør det.

Problemet hos Saxo var altså ikke, efter min mening, at en abonnement kunne vælge at misbruge deres streamingabonnement til at tage kopier af ebøger. Problemet var, at selv en ikke-abonnement kunne gøre det.

Om at undersøge hvordan apps virker og opdage sikkerhedshuller

Hvis du tænker på selv at give dig i kast med at undersøge apps, API’er og eventuelle sikkerhedshuller, er det vigtigt, du har hjertet på det rette sted.

Brancheorganisationen IT-Branchen har udarbejdet et kodeks, som handler om hvordan privatpersoner og virksomheder bør opføre sig, når der opdages og indrapporteres sikkerhedshuller.

Som privatperson er det vigtigt, at:

  • Du ikke udnytter en utilsigtet adgang til data. Straffeloven siger, du ikke uberettiget må skaffe dig adgang til andres data. Ophavsretsloven siger, du ikke må dele ophavsretsbeskyttede værker med andre. Du kan også (måske uden selv at have opdaget det) have accepteret nogle vilkår for brug af en app, som ejeren af appen måske kan bruge til at anlægge en sag mod dig, som de måske/måske ikke kan vinde.
  • Du hurtigst muligt giver virksomheden besked om sikkerhedshullerne, så den kan rette dem.
  • Du ikke deler din viden om sikkerhedshuller med andre, fx dine venner eller medierne. Det ville medføre, at nogle kunne udnytte hullerne og begå ulovligheder.

Læs IT-Branchens glimrende vejledning til anmeldere af sikkerhedsbrister.

Til gengæld for din ædelhed bør virksomheden:

  • Behandle henvendelsen fra dig fortroligt
  • Handle på henvendelsen og løse sikkerhedsproblemerne inden for rimelig tid
  • Undlade at melde dig til politiet, hvis du (anmelderen) har handlet i overensstemmelse med kodekset

Du kan ikke være sikker på, at virksomheden handler ædelt. Derfor valgte jeg selv at kontakte Saxo anonymt, for at høre om deres ædelhed, inden jeg fortalte om det sikkerhedshul, jeg havde opdaget.

Forberedelsesfasen

Android-emulator

Det første man skal bruge for at kunne undersøge mobilapps til Android er en emulator, så apps kan køre på ens PC og man har mulighed for at overvåge trafikken.

Jeg bruger en emulator, der hedder Nox. Den er mest lavet til at kunne spille spil og er fyldt med reklamer og sikkert også overvågning. Men: Nox gør det let at hente, installere og eksportere apps, og at begynde at overvåge trafikken til og fra dem.

For at overvåge trafikken på moderne Android-versioner er det nødvendigt først at pille lidt ved den app, hvis trafik man vil overvåge.

(Nogle apps med højere sikkerhedsniveau, bruger noget der hedder “SSL pinning” til at forhindre trafikovervågning. Det gør det væsentligt sværere at overvåge trafikken, end hvad jeg beskriver her.)

Rette i appen for at tillade traffikovervågning

I Nox installerede jeg Saxos app og eksporterede appen som apk-fil.

Det er nemt at eksportere en apk-fil fra en app ud af emulatoren Nox

Derefter brugte jeg værktøjet Apktool til at dekompilere appen. Værktøjet er gratis, kræver Java og er ret nemt at bruge.

Sådan ser Saxos app ud, når Apktool har haft fat i den.

For at kunne overvåge trafikken skal appen tillade, at jeg bruger andre SSL-certifikater end dem, der kommer med Android.

I mappen res/xml oprettede jeg filen network_security_config.xml (nogle apps har den allerede) og satte det her indhold ind. Det fortæller, at jeg både stoler på systemcertifikater og brugerinstallerede certifikater:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config>
        <trust-anchors>
            <certificates src="system" />
            <certificates src="user" />
        </trust-anchors>
    </base-config>
</network-security-config>

Derefter åbnede jeg AndroidManifest.xml og indsatte stien på den nye fil i tagget “application”.

Før:

<application android:allowBackup="false" android:appComponentFactory="androidx.core.app.CoreComponentFactory" android:hardwareAccelerated="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:largeHeap="true" android:name="com.saxo.read.saxoread.application.SaxoReadApplication" android:resizeableActivity="false" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/SaxoTheme">

Efter:

<application android:networkSecurityConfig="@xml/network_security_config" android:allowBackup="false" android:appComponentFactory="androidx.core.app.CoreComponentFactory" android:hardwareAccelerated="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:largeHeap="true" android:name="com.saxo.read.saxoread.application.SaxoReadApplication" android:resizeableActivity="false" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/SaxoTheme">

Med disse små rettelser på plads, brugte jeg Apktool til at pakke en ny apk-fil og signerede filen med et værktøj, der hedder Uber Apk Signer.

Min modificerede app var klar, og jeg trak-og-slap den over i Nox for at installere den.

Overvågning af app-trafik

Jeg brugte programmet Charles til at overvåge trafikken fra Saxos app. Kort fortalt installerer man Charles’ SSL-certifikat og sætter Nox op til at bruge Charles som proxy for internettrafik.

Hul igennem

Hvis alt er gået godt, kan det se sådan her ud, når Charles trafikovervåger. Her er fx oprettelsen af en bruger og de første par handlinger i Saxos app:

Hul igennem til traffikovervågning med Charles

Undersøgelsesfasen

Når først trafikken til og fra en app kan overvåges, er det bare at begynde at bruge app’en for at finde ud af, hvordan dens API virker.

Det jeg fandt ud af, var et problem ved Saxos app, var:

  1. Når man søger med Saxos app, returneres en række id-numre på bøger
  2. Søgning i appen kræver ikke en Premium-konto, alle kan oprette en konto og søge for at se udvalget af bøger
  3. Download-adressen på en bog til offline-læsning kunne regnes ud alene ud fra en bogs id-nummer
  4. Downloadede bøger var krypterede, men:
  5. Appens nøgle til at dekryptere downloadede bøger, så de kunne læses offline, var meget nem at finde

Problemet betød, at brugere uden abonnement kunne få adgang til bøger uden abonnement.

Brugere med abonnement kunne få adgang til bøger uden at deres download blev registreret gennem Saxos API og dermed, formoder jeg, uden at Saxo kunne honorere bogens forlag og i sidste ende bogens forfatter.

Muligvis – det har jeg ikke testet – gjorde hullet også, at eventuelle check, som Saxo har af, hvor mange bøger en bruger kan hente, blev sat ud af spillet.

I de næste afsnit prøver jeg at tage dig med gennem undersøgelsesfasen.

Download af bøger til offline-læsning

Når jeg downloade en bog hos Saxo, kunne jeg se, at der blev spurgt efter en fil herfra:

Når jeg forsøgte at tilgå adressen i en browser, kunne jeg downloade filen. Men, som adressen afslører, er filerne krypteret (“encrypted-base-files”).

Nøglesammenfald mellem søgeresultater og download-adresse

Så opdagede jeg, at en bogs id gik igen i download-adressen. Her har jeg klikket mig ind på den novellesamling af Jens Blendstrup, som jeg downloadede til offlinebrug. Læg mærke til id:

Id:

a3519df0-182a-4f77-90f7-52a82c5bacf9/user/ee3b4be0-fcab-48b8-944f-e2bffe372f45

Bliver til download-url:

https://readcontentprdcryptbase.blob.core.windows.net/encrypted-base-files/a3/51/9d/f0/a3519df0-182a-4f77-90f7-52a82c5bacf9/a3519df0-182a-4f77-90f7-52a82c5bacf9

Mapperne “a3“, “51“, “9d” og “F0” i stien kommer fra de første otte tegn i id’et: a3519df0.

Altså: Kender du id på en bog, kender du også downloadadressen på den krypterede bog!

Søgeresultater tilgængelige for alle

Jeg fandt også ud af, at søgning i appen, som er åben for både premium-medlemmer og ikke-betalende brugere på appen, udstiller bøgers id. Her er et eksempel, hvor jeg leder efter Puk Damsgårds Arabica:

Næsten alle kan downloade alt!

Når søgningen udstiller bøgers id, og download-adressen til bøger kan findes alene ud fra en bogs id, kunne en ondsindet bruger med god tid, eller med evnen til at programmere en robot, have:

  • Hentet alle ISBN-numre på alle bøger i Saxos streaming-katalog
  • Søgt på alle ISBN-numrene ved hjælp af Saxos app-api og fundet bøgernes id
  • Downloadet alle bøgerne i kataloget

Det kan være, at Saxo havde implementeret noget, der fx blokerede en bruger, der foretog rigtig mange søgninger, eller downloadede rigtigt meget fra downloadserveren. Hvis de havde det, havde det gjort øvelsen med at kopiere hele kataloget lidt sværere. Men kun en lille smule.

Sidste brik: Dekryptering af downloadede bøger

Efter at have fundet muligheden for at downloade krypterede bøger hos Saxo uden at være abonnent, ledte jeg efter en krypteringsnøgle.

Når Saxos app kan læse de downloadede, krypterede bøger, må krypteringsnøglen jo befinde sig i – eller blive leveret til – Saxos app på en eller anden måde.

Jeg fandt nøglen i mine dekompilerede filer fra appen på min harddisk efter at have lavet forskellige søgninger i filerne, som jeg dårligt nok kan huske og heller ikke vil afsløre. Nøglen viste sig at ligge meget dårligt skjult, og med en lille bid Python-kode og modulet Cryptography skrev jeg et lille program, der kunne dekryptere bøgerne:

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend

filename = ""	# Filnavn på krypteret bog
key = ""		# Fra Saxos app
iv = ""			# Fra Saxos app
encoded_key = key.encode('utf-8')
encoded_iv = iv.encode('utf-8')

backend = default_backend()
cipher = Cipher(algorithms.AES(encoded_key), modes.CBC(encoded_iv), backend=backend)

with open(filename, "rb") as encrypted_book:
	encrypted = encrypted_book.read()
	decryptor = cipher.decryptor()
	decrypted = decryptor.update(encrypted) + decryptor.finalize()
	with open(filename + '.epub', "wb") as fout:
		fout.write(decrypted)

Saxos rettelser

Efter at Saxo har opdateret deres app, har jeg genbesøgt appen. Jeg kan se, at:

  • Krypteringsnøglen til Saxos ebøger er blevet skjult en hel del bedre end tidligere.
  • Det er ikke længere nok at have internetadressen på en bog for at downloade den. For at brugeren kan downloade en bog, genererer appen nu en unik nøgle per bog, som downloadserveren vil have for at sende en bog tilbage.

Den gamle version af appen tillod en bruger at springe det trin over, hvor Saxo registererer, at en bog er blevet hentet. I den nye er det kun muligt at downloade en bog, hvis man inden da har spurgt API’et om downloadadressen:

Downloadadresserne på bøger har fået nogle ekstra parametre på, bl.a. en unik “signatur” (“sig”), som API’et returnerer for hver bog, brugeren downloader. Jeg har censureret nogle få bytes her:

De nye downloadadresser er mere komplicerede end tidligere. Jeg ved ikke, hvad alle de nye parametre gør, men mon ikke datoparametrene har noget at gøre med en udløbsdato, hvorefter det ikke længere er muligt at bruge linket?

Derudover har Saxo, har de fortalt mig, lavet andre sikkerhedsopstramninger bag kulisserne.

Skønt!

Hvad betyder ændringerne i Saxos app?

Ændringerne betyder, at ikke-betalende brugere af Saxos app, så vidt jeg kan se, nu er effektivt afskåret fra at kunne tilgå bøger.

Betalende brugere har ikke længere mulighed for at downloade bøger, uden at Saxo kan registrere det og sørge for passende honorering af forfatter og forlag.

Tidsforløb og Saxos kommentarer

Jeg gjorde opmærksom på sikkerhedsproblemet den 22. marts 2021. Jeg kontaktede først Saxos kundeservice den 12. marts og opdagede, så vidt jeg husker, problemet den 11. marts. Saxos opdaterede app blev rullet ud den 17. maj 2021.

Jeg har slettet de bøger, jeg selv har downloadet og dekrypteret i forbindelse med undersøgelsen af Saxos app.

Saxo har haft mulighed for at foreslå rettelser og kommentere dette blogindlæg.

Saxo foreslog at gøre ekstra opmærksom på lovgivningen omkring at få uberettiget adgang til og dele data. Det synes jeg var en god idé, så det har jeg gjort i afsnittet om at undersøge hvordan apps virker og at opdage sikkerhedshuller.

De foreslog også at gøre opmærksom på, at det at dekompilere apps, ændre dem og bygge dem igen, efter deres mening er en juridisk gråzone. Her har din hensigt med at undersøge en app betydning: Hvis du blot er ude på at undersøge, hvordan ting virker, kender jeg ikke noget lovgivning, der siger, at du gør noget forkert (hvis du gør, vil jeg gerne høre om det). Hvis du er ude på at få uberettiget adgang til data eller ophavsretsbeskyttet materiale er det en anden sag – så er du ude på at bryde loven.

Opdateret program til at hente dine transaktionsdata fra Saxo Bank

Saxo Bank har opdateret nogle småting i deres login-procedure, og derfor har jeg opdateret mit program til at hente transaktionsdata.

Læs om hvordan jeg har udviklet programmet i indlægget om den gamle udgave.

Her er den opdaterede kode:

# -*- coding: utf-8 -*-
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com
"""This program logs into a Saxo Bank account and lets you make API requests."""

import requests 
from datetime import datetime
from datetime import date
from bs4 import BeautifulSoup

# USER ACCOUNT AND PERIOD DATA. SHOULD BE EDITED FOR YOUR NEEDS #

# Saxo user account credentials
user = '' # your user id
password = '' # your password

# Start date (start of period for transactions) and date today used for extraction of transactions
startdate = '2019-01-01'
today = date.today()
enddate = datetime.strftime(today, '%Y-%m-%d')

# LOGIN TO SAXO BANK

# Start requests session and set user agent
session = requests.Session()
session.headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0'

# Visit login page and get AuthnRequest token value from input form
url = 'https://www.saxoinvestor.dk/Login/da/'
request = session.get(url)
soup = BeautifulSoup(request.text, "html.parser")
input = soup.find_all('input', {"id":"AuthnRequest"})
authnrequest = input[0]["value"]

# Login step 1: Submit username, password and token and get another token back
url = 'https://www.saxoinvestor.dk/Login/da/'
request = session.post(url, data = {'field_userid': user, 'field_password': password, 'AuthnRequest': authnrequest})
soup = BeautifulSoup(request.text, "html.parser")
input = soup.find_all('input', {"name":"SAMLResponse"})

# Most of the time this works
if input:
	samlresponse = input[0]["value"]
# But sometimes there's a disclaimer that Saxo Bank would like you to accept
else:
	input = soup.find_all('input')
	inputs = {}
	try:
		for i in input:
			inputs[i['name']] = i['value']
	except:
		pass
	url = 'https://www.saxotrader.com/disclaimer'
	request = session.post(url, data=inputs)
	cook = request.cookies['DisclaimerApp']
	returnurl = cook[cook.find("ReturnUrl")+10:cook.find("&IsClientStation")]
	url = 'https://live.logonvalidation.net/complete-app-consent/' + returnurl[returnurl.find("complete-app-consent/")+21:]
	request = session.get(url)
	soup = BeautifulSoup(request.text, "html.parser")
	input = soup.find_all('input', {"name":"SAMLResponse"})
	samlresponse = input[0]["value"]	

# Login step 2: Get bearer token necessary for API requests
url = 'https://www.saxoinvestor.dk/investor/login.sso.ashx'
response = session.post(url, data = {'SAMLResponse': samlresponse})

response_text = response.text
bearer = response_text[response_text.find("BEARER"):response_text.find("/exp/")]

# START API CALLS
# Documentation at https://www.developer.saxo/openapi/learn

# Set bearer token as header
headers = {'Authorization': bearer}

# First API request gets Client Key which is used for most API calls
# See https://www.developer.saxo/openapi/learn/the-tutorial for expected return data
url = 'https://www.saxoinvestor.dk/openapi/port/v1/clients/me'
r = requests.get(url, headers=headers)

clientdata = r.json()
clientkey = clientdata['ClientKey']

# Example API call #1
url = 'https://www.saxoinvestor.dk/openapi/cs/v1/reports/aggregatedAmounts/' + clientkey + '/' + startdate + '/' + enddate + '/'
r = requests.get(url, headers=headers)
data = r.json()

# Working on that data to add some transaction types to personal system
saxoaccountname = "Aktiesparekonto: Saxo Bank"
currency = "DKK"
saxotransactions = ""

for item in data['Data']:
	if item['AffectsBalance'] == True:
		date = item['Date']
		amount = item['Amount']
		amount_str = str(amount).replace(".",",")
		if item['UnderlyingInstrumentDescription'] == 'Cash deposit or withdrawal' or item['UnderlyingInstrumentDescription'] == 'Cash inter-account transfer':
			if amount > 0:
				transactiontype = 'INDBETALING'
			elif amount < 0:
				transactiontype = 'HÆVNING'
			saxotransactions += ";" + date + ";" + date + ";" + date + ";" + transactiontype + ";;;;;;;;" + amount_str + ";" + currency + ";;;;;;;;;" + saxoaccountname + "\r\n"
		if item['AmountTypeName'] == 'Corporate Actions - Cash Dividends':
			transactiontype = "UDB."
			if item['InstrumentDescription'] == "Novo Nordisk B A/S":
				paper = "Novo B"
				papertype = "Aktie"
			if item['InstrumentDescription'] == "Tryg A/S":
				paper = "TRYG"
				papertype = "Aktie"
			saxotransactions += ";" + date + ";" + date + ";" + date + ";" + transactiontype + ";" + paper + ";" + papertype + ";;;;;;" + amount_str + ";" + currency + ";;;;;;;;;" + saxoaccountname + "\n"

# Example API call #2		
url = "https://www.saxoinvestor.dk/openapi/cs/v1/reports/trades/" + clientkey + "?fromDate=" + startdate + "&" + "toDate=" + enddate
r = requests.get(url, headers=headers)
data = r.json()

# Working on that data to add trades to personal system
for item in data['Data']:
	date = item['AdjustedTradeDate']
	numberofpapers = str(int(item['Amount']))
	amount_str = str(item['BookedAmountAccountCurrency']).replace(".",",")
	priceperpaper = str(item['BookedAmountAccountCurrency'] / item['Amount']).replace(".",",")
	if item['TradeEventType'] == 'Bought':
		transactiontype = "KØBT"
	if item['AssetType'] == 'Stock':
		papertype = "Aktie"
	if item['InstrumentDescription'] == "Novo Nordisk B A/S":
		paper = "Novo B"
		isin = "DK0060534915"
	if item['InstrumentDescription'] == "Tryg A/S":
		paper = "TRYG"
		isin = "DK0060636678"
	saxotransactions += ";" + date + ";" + date + ";" + date + ";" + transactiontype + ";" + paper + ";" + papertype + ";" + isin + ";" + numberofpapers + ";" + priceperpaper + ";;;" + amount_str + ";" + currency + ";;;;;;;;;" + saxoaccountname + "\n"

RSS-feeds med Django

En Wallnot-bruger spurgte mig om ikke Wallnot burde have et RSS-feed? Jo da.

Det viste sig at den slags er indbygget i Django og meget nemt at lave.

Jeg oprettede feeds.py med to forskellige feeds. Et for alle artikler fra Wallnot, et for artikler for bestemte medier:

from django.contrib.syndication.views import Feed
from wall.models import Article
from django.urls import reverse

class RssFeed(Feed):
	title = "Nyeste artikler fra wallnot.dk"
	link = "/rss/"
	description = "De allernyeste artikler uden paywall fra wallnot.dk"
	def items(self):
		return Article.objects.filter(paywall_detected=False).order_by('-date')[:20]

	def item_title(self, item):
		return item.title

	def item_description(self, item):
		return "Artikel fra " + item.get_medium_display()

	def item_link(self, item):
		return item.url

class RssFeedMedium(Feed):
	title = "Nyeste artikler fra wallnot.dk"
	description = "De allernyeste artikler uden paywall fra wallnot.dk"
	def get_object(self, request, medium):
		return medium
	
	def link(self, obj):
		return "/rss/" + obj
	
	def items(self, obj):
		return Article.objects.filter(paywall_detected=False, medium=obj).order_by('-date')[:20]

	def item_title(self, item):
		return item.title

	def item_description(self, item):
		return "Artikel fra " + item.get_medium_display()

	def item_link(self, item):
		return item.url

Og pegede på dem fra urls.py:

from django.urls import path
from . import views
from . import feeds

urlpatterns = [
	path('', views.index, name='index'),
	path('links', views.linkindex, name='linkindex'),
	path('privatliv', views.privacy, name='privacy'),
	path('om', views.about, name='wabout'),
	path('rss', feeds.RssFeed(), name='rssfeed'),
	path('rss/<str:medium>', feeds.RssFeedMedium(), name='rssfeed_medium'),
]

Og vupti!

Du finder RSS-feeds på wallnot.dk lige ved siden af Twitter-logoet.

Et lille problem (nu med løsning!) hos Nordnet

Af forskellige grunde er mit styresystem og min browser (Firefox) engelsksproget. Og af uransagelige årsager har Nordnet lanceret et ufærdigt engelsk browserinterface, hvor hele navigationen mangler.

Her er menuen med dansk interface:

Og sådan ser det ud på engelsk:

Der mangler noget!

Som den gode samfundsborger jeg er, forsøgte jeg at få problemet løst. Men hvordan?

Kundeservice!

Jeg startede med at skrive en flink besked:

Og fik et flinkt, men ubrugeligt, standardsvar:

Blev jeg læst? Jeg følte mig i hvert fald ikke rigtigt set. Det er fint, at Nordnet lytter til kunders “tilbagemeldinger”, men som man kan læse om i managementlitteraturen, handler indtryk af virksomheder/organisationer ikke om, hvad de siger de gør, men om hvad de rent faktisk gør (hvis det altså bliver opdaget, det de gør).

Jeg prøver igen…

Lidt mindre høfligt, lidt mere tydeligt:

Min begyndende skepsis mødes med mistro. Er du sikker på, du ser, hvad du ser og dokumenterer for os med fine skærmbilleder?

Jeg skruer op for dokumentationen og vedhæfter en notits, jeg fik, om at bekræfte mine kontaktoplysninger. Den ville være sjov, hvis det ikke var så hamrende uprofessionelt at vise sådan noget til sine kunder. Ej, den er faktisk lidt sjov:

Bacon ipsum dolor amet landjaeger bacon bresaola… jo tak.

Jeg skrev til mit skærmbillede:

Nu var Christine tilbage på sagen, men hun har nok brugt ca. 14 millisekunder for lidt på at kigge på det skærmbillede, jeg vedhæftede. I hvert fald misser hun pointen totalt:

Alle Nordnets kunder er åbenbart forskellige kødprodukter…

Hvad gør man så?

Jeg har tweetet:

Jeg har forsøgt at LinkedIn-connecte og gøre opmærksom på problemet:

En flink udvikler
En flink Chief Technical Officer (ifølge https://nordnetab.com/governance/group-management/)

Og til allersidst har jeg været på Github:

https://github.com/nordnet/ui/issues/909

Konklusionen på det hele

Indtil videre er det ikke lykkes mig at komme i kontakt med en person hos Nordnet med kompetencer eller vilje til at rette op på fejlen.

Det er ikke engang lykkes mig at komme i kontakt med en person hos Nordnet med kompetencer til at give en personen med kompetencer til at løse problemet besked om problemet.

Jeg tror, at det her viser noget om mig og noget om Nordnet:

  • Jeg er villig til at bruge (for) lang tid på services/problemer, der irriterer mig.
  • Nordnet er – som mange andre større virksomheder – villig til at bruge så få ressourcer på kundeservice, at de risikerer at rigtige problemer ikke når frem til de rigtige personer.

Opdatering 19. maj 2021

I går eftermiddags forsøgte jeg at gøre Nordnets landechef for Danmark, Anne Buchardt opmærksom på problemet. Hun satte Rasmus Järborg, som er Chief Product Officer på sagen, og Rasmus satte en udvikler til at kigge nærmere på det.

Det hjalp!

Mindre end et døgn senere havde Nordnet fikset problemet. Suveræn – men meget dyr – kundeservice.

Hurtig reaktion fra Anne
Fremragende kundesupport fra Rasmus

Aulas API: En opdatering

Fordi jeg blev kontaktet af nogle flinke skoleansatte, der er i gang med at lette deres hverdag med AULA, har jeg opdateret mit Python-script med eksempler på, hvad man kan lave i AULA, uden rent faktisk at pege sin browser på AULA.

Det nye eksempel (eksempel 6) viser, hvordan man kan oprette en kalenderbegivenhed i AULA (de gamle eksempler nøjedes med at læse data fra systemet).

Og til de nysgerrige: Nej, man kan ikke (umiddelbart) oprette begivenheder med Javascript i “beskrivelsen”. Ja, man kan godt lave begivenheder med inline-css i beskrivelsen, så begivenheder ser ret specielle ud, rent visuelt.

# aula.py
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com
''' An example of how to log in to the Danish LMS Aula (https://aula.dk) and
extract data from the API. Could be further developed to also submit data and/or to
create your own web or terminal interface(s) for Aula.'''

# Imports
import requests					# Perform http/https requests
from bs4 import BeautifulSoup	# Parse HTML pages
import json						# Needed to print JSON API data

# User info
user = {
	'username': '',
	'password': ''
}

# Start requests session
session = requests.Session()
	
# Get login page
url = 'https://login.aula.dk/auth/login.php?type=unilogin'
response = session.get(url)

# Login is handled by a loop where each page is first parsed by BeautifulSoup.
# Then the destination of the form is saved as the next url to post to and all
# inputs are collected with special cases for the username and password input.
# Once the loop reaches the Aula front page the loop is exited. The loop has a
# maximum number of iterations to avoid an infinite loop if something changes
# with the Aula login.
counter = 0
success = False
while success == False and counter < 10:
	try:
		# Parse response using BeautifulSoup
		soup = BeautifulSoup(response.text, "lxml")
		# Get destination of form element (assumes only one)
		url = soup.form['action']	
		
		# If form has a destination, inputs are collected and names and values
		# for posting to form destination are saved to a dictionary called data
		if url:
			# Get all inputs from page
			inputs = soup.find_all('input')
			# Check whether page has inputs
			if inputs:
				# Create empty dictionary 
				data = {}
				# Loop through inputs
				for input in inputs:
					# Some inputs may have no names or values so a try/except
					# construction is used.
					try:
						# Login takes place in single input steps, which
						# is the reason for the if/elif construction
						# Save username if input is a username field
						if input['name'] == 'username':
							data[input['name']] = user['username']
						# Save password if input is a password field
						elif input['name'] == 'password':
							data[input['name']] = user['password']
						# For employees the login procedure has an additional field to select a role
						# If an employee needs to login in a parent role, this value needs to be changed
						elif input['name'] == 'selected-aktoer':
							data[input['name']] = "MEDARBEJDER_EKSTERN"
						# For all other inputs, save name and value of input
						else:
							data[input['name']] = input['value']
					# If input has no value, an error is caught but needs no handling
					# since inputs without values do not need to be posted to next
					# destination.
					except:
						pass
			# If there's data in the dictionary, it is submitted to the destination url
			if data:
				response = session.post(url, data=data)
			# If there's no data, just try to post to the destination without data
			else:
				response = session.post(url)
			# If the url of the response is the Aula front page, loop is exited
			if response.url == 'https://www.aula.dk:443/portal/':
				success = True
	# If some error occurs, try to just ignore it
	except:
		pass
	# One is added to counter each time the loop runs independent of outcome
	counter += 1

# Login succeeded without an HTTP error code and API requests can begin	
if success == True and response.status_code == 200:
	print("Login lykkedes")
	
	# All API requests go to the below url
	# Each request has a number of parameters, of which method is always included
	# Data is returned in JSON
	url = 'https://www.aula.dk/api/v11/'

	### First API request. This request must be run to generate correct correct cookies for subsequent requests. ###
	params = {
		'method': 'profiles.getProfilesByLogin'
		}
	# Perform request, convert to json and print on screen
	response_profile = session.get(url, params=params).json()
	print(json.dumps(response_profile, indent=4))
	
	### Second API request. This request must be run to generate correct correct cookies for subsequent requests. ###
	params = {
		'method': 'profiles.getProfileContext',
		'portalrole': 'guardian',	# 'guardian' for parents (or other guardians), 'employee' for employees
	}
	# Perform request, convert to json and print on screen
	response_profile_context = session.get(url, params=params).json()
	print(json.dumps(response_profile_context, indent=4))

	# Loop to get institutions and children associated with profile and save
	# them to lists
	institutions = []
	institution_profiles = []
	children = []
	for institution in response_profile_context['data']['institutions']:
		institutions.append(institution['institutionCode'])
		institution_profiles.append(institution['institutionProfileId'])
		for child in institution['children']:
			children.append(child['id'])
	
	children_and_institution_profiles = institution_profiles + children

	### Third example API request, uses data collected from second request ###
	params = {
		'method': 'notifications.getNotificationsForActiveProfile',
		'activeChildrenIds[]': children,
		'activeInstitutionCodes[]': institutions
	}
	
	# Perform request, convert to json and print on screen
	notifications_response = session.get(url, params=params).json()
	print(json.dumps(notifications_response, indent=4))
	
	### Fourth example API request, only succeeds when the third has been run before ###
	params = {
		'method': 'messaging.getThreads',
		'sortOn': 'date',
		'orderDirection': 'desc',
		'page': '0'
	}
	
	# Perform request, convert to json and print on screen
	response_threads = session.get(url, params=params).json()
	print(json.dumps(response_threads, indent=4))
	
	### Fifth example. getAllPosts uses a combination of children and instituion profiles. ###
	params = {
		'method': 'posts.getAllPosts',
		'parent': 'profile',
		'index': "0",
		'institutionProfileIds[]': children_and_institution_profiles,
		'limit': '10'
	}

	# Perform request, convert to json and print on screen
	response_threads = session.get(url, params=params).json()
	print(json.dumps(response_threads, indent=4))
	
	### Sixth example. Posting a calender event. ###
	params = (
		('method', 'calendar.createSimpleEvent'),
	)
	
	# Manually setting the cookie "profile_change". This probably has to do with posting as a parent.
	session.cookies['profile_change'] = '2'
	
	# Csrfp-token is manually added to session headers.
	session.headers['csrfp-token'] = session.cookies['Csrfp-Token']

	data = {
		'title': 'This is a test',
		'description': '<p>A really nice test.</p>',
		'startDateTime': '2021-05-18T14:30:00.0000+02:00',
		'endDateTime': '2021-05-18T15:00:00.0000+02:00',
		'startDate': '2021-05-17',
		'endDate': '2021-05-17',
		'startTime': '12:00:19',
		'endTime': '12:30:19',
		'id': '',
		'institutionCode': response_profile['data']['profiles'][0]['institutionProfiles'][0]['institutionCode'],
		'creatorInstProfileId': response_profile['data']['profiles'][0]['institutionProfiles'][0]['id'],
		'type': 'event',
		'allDay': False,
		'private': False,
		'primaryResource': {},
		'additionalLocations': [],
		'invitees': [],
		'invitedGroups': [],
		'invitedGroupIds': [],
		'invitedGroupHomes': [],
		'responseRequired': True,
		'responseDeadline': None,
		'resources': [],
		'attachments': [],
		'oldStartDateTime': '',
		'oldEndDateTime': '',
		'isEditEvent': False,
		'addToInstitutionCalendar': False,
		'hideInOwnCalendar': False,
		'inviteeIds': [],
		'additionalResources': [],
		'pattern': 'never',
		'occurenceLimit': 0,
		'weekdayMask': [
			False,
			False,
			False,
			False,
			False,
			False,
			False
		],
		'maxDate': None,
		'interval': 0,
		'lessonId': '',
		'noteToClass': '',
		'noteToSubstitute': '',
		'eventId': '',
		'isPrivate': False,
		'resourceIds': [],
		'additionalLocationIds': [],
		'additionalResourceIds': [],
		'attachmentIds': []
	}
	
	response_calendar = session.post(url, params=params, json=data).json()
	print(json.dumps(response_calendar, indent=4))

# Login failed for some unknown reason
else:
	print("Noget gik galt med login")