Make session saving and lookup more reusable.
[fulbank.git] / fulbank / fsb.py
CommitLineData
66c36016
FT
1import json, http.cookiejar, binascii, time, datetime, pickle, urllib.error, io
2from PIL import Image
8e60b2da
FT
3from urllib import request, parse
4from bs4 import BeautifulSoup as soup
717d54fc 5from . import currency, auth, data
8e60b2da
FT
6soupify = lambda cont: soup(cont, "html.parser")
7
8apibase = "https://online.swedbank.se/TDE_DAP_Portal_REST_WEB/api/"
9loginurl = "https://online.swedbank.se/app/privat/login"
10serviceid = "B7dZHQcY78VRVz9l"
11
12class fmterror(Exception):
13 pass
14
f99c3f74 15class autherror(auth.autherror):
8e60b2da
FT
16 pass
17
f78b790d
FT
18class jsonerror(Exception):
19 def __init__(self, code, data, headers):
20 self.code = code
21 self.data = data
22 self.headers = headers
23
24 @classmethod
25 def fromerr(cls, err):
26 cs = err.headers.get_content_charset()
27 if cs is None:
28 cs = "utf-8"
29 data = json.loads(err.read().decode(cs))
30 return cls(err.code, data, err.headers)
31
8e60b2da 32def resolve(d, keys, default=fmterror):
d791f2f2 33 def err(key):
8e60b2da 34 if default is fmterror:
d791f2f2 35 raise fmterror(key)
8e60b2da
FT
36 return default
37 def rec(d, keys):
38 if len(keys) == 0:
39 return d
40 if isinstance(d, dict):
41 if keys[0] not in d:
d791f2f2
FT
42 return err(keys[0])
43 return rec(d[keys[0]], keys[1:])
44 elif isinstance(d, list):
45 if not 0 <= keys[0] < len(d):
46 return err(keys[0])
8e60b2da
FT
47 return rec(d[keys[0]], keys[1:])
48 else:
d791f2f2 49 return err(keys[0])
8e60b2da
FT
50 return rec(d, keys)
51
52def linkurl(ln):
53 if ln[0] != '/':
54 raise fmterror("unexpected link url: " + ln)
55 return parse.urljoin(apibase, ln[1:])
56
57def getdsid():
58 with request.urlopen(loginurl) as resp:
59 if resp.code != 200:
60 raise fmterror("Unexpected HTTP status code: " + str(resp.code))
61 doc = soupify(resp.read())
62 dsel = doc.find("div", id="cust-sess-id")
63 if not dsel or not dsel.has_attr("value"):
64 raise fmterror("DSID DIV not on login page")
65 return dsel["value"]
66
67def base64(data):
68 return binascii.b2a_base64(data).decode("ascii").strip().rstrip("=")
69
717d54fc 70class transaction(data.transaction):
61fd054f
FT
71 def __init__(self, account, data):
72 self.account = account
73 self._data = data
74
8c456209
FT
75 _datefmt = "%Y-%m-%d"
76
61fd054f 77 @property
8e415ee7 78 def value(self): return currency.currency.get(resolve(self._data, ("currency",))).parse(resolve(self._data, ("amount",)))
61fd054f 79 @property
8c456209
FT
80 def message(self): return resolve(self._data, ("description",))
81 @property
82 def date(self):
83 p = time.strptime(resolve(self._data, ("accountingDate",)), self._datefmt)
84 return datetime.date(p.tm_year, p.tm_mon, p.tm_mday)
85
717d54fc 86class txnaccount(data.txnaccount):
61fd054f
FT
87 def __init__(self, sess, id, idata):
88 self.sess = sess
89 self.id = id
90 self._data = None
91 self._idata = idata
92
93 @property
94 def data(self):
95 if self._data is None:
96 self._data = self.sess._jreq("v5/engagement/account/" + self.id)
97 return self._data
98
99 @property
100 def number(self): return resolve(self.data, ("accountNumber",))
101 @property
102 def clearing(self): return resolve(self.data, ("clearingNumber",))
103 @property
104 def fullnumber(self): return resolve(self.data, ("fullyFormattedNumber",))
105 @property
0ba154d0
FT
106 def balance(self): return currency.currency.get(resolve(self.data, ("balance", "currencyCode"))).parse(resolve(self.data, ("balance", "amount")))
107 @property
61fd054f
FT
108 def name(self): return resolve(self._idata, ("name",))
109
110 def transactions(self):
111 pagesz = 50
8c456209
FT
112 page = 1
113 while True:
114 data = self.sess._jreq("v5/engagement/transactions/" + self.id, transactionsPerPage=pagesz, page=page)
115 txlist = resolve(data, ("transactions",))
116 if len(txlist) < 1:
117 break
118 for tx in txlist:
119 yield transaction(self, tx)
120 page += 1
61fd054f 121
717d54fc 122class cardtransaction(data.transaction):
d791f2f2
FT
123 def __init__(self, account, data):
124 self.account = account
125 self._data = data
126
127 _datefmt = "%Y-%m-%d"
128
129 @property
130 def value(self):
131 am = resolve(self._data, ("localAmount",))
132 return currency.currency.get(resolve(am, ("currencyCode",))).parse(resolve(am, ("amount",)))
133 @property
134 def message(self): return resolve(self._data, ("description",))
135 @property
136 def date(self):
137 p = time.strptime(resolve(self._data, ("date",)), self._datefmt)
138 return datetime.date(p.tm_year, p.tm_mon, p.tm_mday)
139
717d54fc 140class cardaccount(data.cardaccount):
d791f2f2
FT
141 def __init__(self, sess, id, idata):
142 self.sess = sess
143 self.id = id
144 self._data = None
145 self._idata = idata
146
147 @property
148 def data(self):
149 if self._data is None:
150 self._data = self.sess._jreq("v5/engagement/cardaccount/" + self.id)
151 return self._data
152
153 @property
154 def number(self): return resolve(self.data, ("cardAccount", "cardNumber"))
155 @property
156 def balance(self):
157 cc = resolve(self.data, ("transactions", 0, "localAmount", "currencyCode"))
158 return currency.currency.get(cc).parse(resolve(self.data, ("cardAccount", "currentBalance")))
159 @property
160 def name(self): return resolve(self._idata, ("name",))
161
162 def transactions(self):
163 pagesz = 50
164 page = 1
165 while True:
166 data = self.sess._jreq("v5/engagement/cardaccount/" + self.id, transactionsPerPage=pagesz, page=page)
167 txlist = resolve(data, ("transactions",))
168 if len(txlist) < 1:
169 break
170 for tx in txlist:
171 yield cardtransaction(self, tx)
172 page += 1
173
be69f65b 174class session(data.session):
8e60b2da
FT
175 def __init__(self, dsid):
176 self.dsid = dsid
177 self.auth = base64((serviceid + ":" + str(int(time.time() * 1000))).encode("ascii"))
178 self.jar = request.HTTPCookieProcessor()
179 self.jar.cookiejar.set_cookie(http.cookiejar.Cookie(
180 version=0, name="dsid", value=dsid, path="/", path_specified=True,
181 domain=".online.swedbank.se", domain_specified=True, domain_initial_dot=True,
182 port=None, port_specified=False, secure=False, expires=None,
183 discard=True, comment=None, comment_url=None,
184 rest={}, rfc2109=False))
185 self.userid = None
61fd054f 186 self._accounts = None
8e60b2da
FT
187
188 def _req(self, url, data=None, ctype=None, headers={}, method=None, **kws):
189 if "dsid" not in kws:
190 kws["dsid"] = self.dsid
191 kws = {k: v for (k, v) in kws.items() if v is not None}
192 url = parse.urljoin(apibase, url + "?" + parse.urlencode(kws))
193 if isinstance(data, dict):
194 data = json.dumps(data).encode("utf-8")
195 ctype = "application/json;charset=UTF-8"
196 req = request.Request(url, data=data, method=method)
197 for hnam, hval in headers.items():
198 req.add_header(hnam, hval)
199 if ctype is not None:
200 req.add_header("Content-Type", ctype)
201 req.add_header("Authorization", self.auth)
202 self.jar.https_request(req)
203 with request.urlopen(req) as resp:
61fd054f 204 if resp.code != 200 and resp.code != 201:
8e60b2da
FT
205 raise fmterror("Unexpected HTTP status code: " + str(resp.code))
206 self.jar.https_response(req, resp)
207 return resp.read()
208
209 def _jreq(self, *args, **kwargs):
210 headers = kwargs.pop("headers", {})
211 headers["Accept"] = "application/json"
f78b790d
FT
212 try:
213 ret = self._req(*args, headers=headers, **kwargs)
214 except urllib.error.HTTPError as e:
215 if e.headers.get_content_type() == "application/json":
216 raise jsonerror.fromerr(e)
8e60b2da
FT
217 return json.loads(ret.decode("utf-8"))
218
61fd054f
FT
219 def _postlogin(self):
220 auth = self._jreq("v5/user/authenticationinfo")
221 uid = auth.get("identifiedUser", "")
222 if uid == "":
223 raise fmterror("no identified user even after successful authentication")
224 self.userid = uid
225 prof = self._jreq("v5/profile/")
226 if len(prof["banks"]) != 1:
227 raise fmterror("do not know the meaning of multiple banks")
228 rolesw = linkurl(resolve(prof["banks"][0], ("privateProfile", "links", "next", "uri")))
229 self._jreq(rolesw, method="POST")
230
66c36016
FT
231 def auth_token(self, user, conv=None):
232 if conv is None:
233 conv = auth.default()
234 try:
235 data = self._jreq("v5/identification/securitytoken/challenge", data = {
236 "userId": user,
237 "useEasyLogin": "false",
238 "generateEasyLoginId": "false"})
239 except jsonerror as e:
240 if e.code == 400:
241 flds = resolve(e.data, ("errorMessages", "fields"), False)
242 if isinstance(flds, list):
243 for fld in flds:
244 if resolve(fld, ("field",), None) == "userId":
245 raise autherror(fld["message"])
246 raise
247 if data.get("useOneTimePassword"):
248 raise fmterror("unexpectedly found useOneTimePassword")
249 if data.get("challenge") != "":
250 raise fmterror("unexpected challenge: " + str(data.get("challenge")))
251 if not isinstance(data.get("imageChallenge"), dict) or resolve(data, ("imageChallenge", "method")) != "GET":
252 raise fmterror("invalid image challenge: " + str(data.get("imageChallenge")))
253 iurl = linkurl(resolve(data, ("imageChallenge", "uri")))
254 vfy = linkurl(resolve(data, ("links", "next", "uri")))
255 img = Image.open(io.BytesIO(self._req(iurl)))
256 conv.image(img)
257 response = conv.prompt("Token response: ", True)
258 try:
259 data = self._jreq(vfy, data={"response": response})
260 except jsonerror as e:
261 msgs = resolve(e.data, ("errorMessages", "general"), False)
262 if isinstance(msgs, list):
263 for msg in msgs:
264 if msg.get("message"):
265 raise autherror(msg.get("message"))
266 raise
267 if not data.get("authenticationRole", ""):
268 raise fmterror("authentication appears to have succeded, but there is no authenticationRole: " + str(data))
269 self._postlogin()
270
f4de0bf1
FT
271 def auth_bankid(self, user, conv=None):
272 if conv is None:
273 conv = auth.default()
f78b790d
FT
274 try:
275 data = self._jreq("v5/identification/bankid/mobile", data = {
276 "userId": user,
277 "useEasyLogin": False,
278 "generateEasyLoginId": False})
279 except jsonerror as e:
280 if e.code == 400:
281 flds = resolve(e.data, ("errorMessages", "fields"), False)
282 if isinstance(flds, list):
283 for fld in flds:
284 if resolve(fld, ("field",), None) == "userId":
285 raise autherror(fld["message"])
286 raise
8cda37c5 287 st = data.get("status")
8e60b2da 288 vfy = linkurl(resolve(data, ("links", "next", "uri")))
f4de0bf1 289 fst = None
8e60b2da 290 while True:
f4de0bf1
FT
291 if st in {"USER_SIGN", "CLIENT_NOT_STARTED"}:
292 if st != fst:
293 conv.message("Status: %s" % (st,), auth.conv.msg_info)
294 fst = st
8e60b2da 295 elif st == "COMPLETE":
61fd054f 296 self._postlogin()
8e60b2da
FT
297 return
298 elif st == "CANCELLED":
299 raise autherror("authentication cancelled")
86601bc0
FT
300 elif st == "OUTSTANDING_TRANSACTION":
301 raise autherror("another bankid transaction already in progress")
8e60b2da
FT
302 else:
303 raise fmterror("unexpected bankid status: " + str(st))
8cda37c5
FT
304 time.sleep(3)
305 vdat = self._jreq(vfy)
306 st = vdat.get("status")
8e60b2da 307
61fd054f
FT
308 def keepalive(self):
309 data = self._jreq("v5/framework/clientsession")
310 return data["timeoutInMillis"] / 1000
311
312 @property
313 def accounts(self):
314 if self._accounts is None:
04ce2557
FT
315 txndata = self._jreq("v5/engagement/overview")
316 crddata = self._jreq("v5/card/creditcard")
61fd054f 317 accounts = []
04ce2557 318 for acct in resolve(txndata, ("transactionAccounts",)):
d791f2f2 319 accounts.append(txnaccount(self, resolve(acct, ("id",)), acct))
04ce2557 320 for acct in resolve(crddata, ("cardAccounts",)):
d791f2f2 321 accounts.append(cardaccount(self, resolve(acct, ("id",)), acct))
61fd054f
FT
322 self._accounts = accounts
323 return self._accounts
324
8e60b2da
FT
325 def logout(self):
326 if self.userid is not None:
327 self._jreq("v5/identification/logout", method="PUT")
328 self.userid = None
329
330 def close(self):
331 self.logout()
332 self._req("v5/framework/clientsession", method="DELETE")
333
db4731c6
FT
334 def __getstate__(self):
335 state = dict(self.__dict__)
336 state["jar"] = list(state["jar"].cookiejar)
337 return state
338
339 def __setstate__(self, state):
340 jar = request.HTTPCookieProcessor()
341 for cookie in state["jar"]:
342 jar.cookiejar.set_cookie(cookie)
343 state["jar"] = jar
344 self.__dict__.update(state)
345
8e60b2da
FT
346 def __enter__(self):
347 return self
348
349 def __exit__(self, *excinfo):
350 self.close()
351 return False
352
61fd054f
FT
353 def __repr__(self):
354 if self.userid is not None:
355 return "#<fsb.session %s>" % self.userid
356 return "#<fsb.session>"
357
8e60b2da
FT
358 @classmethod
359 def create(cls):
360 return cls(getdsid())