Commit | Line | Data |
---|---|---|
66c36016 FT |
1 | import json, http.cookiejar, binascii, time, datetime, pickle, urllib.error, io |
2 | from PIL import Image | |
8e60b2da FT |
3 | from urllib import request, parse |
4 | from bs4 import BeautifulSoup as soup | |
717d54fc | 5 | from . import currency, auth, data |
8e60b2da FT |
6 | soupify = lambda cont: soup(cont, "html.parser") |
7 | ||
8 | apibase = "https://online.swedbank.se/TDE_DAP_Portal_REST_WEB/api/" | |
9 | loginurl = "https://online.swedbank.se/app/privat/login" | |
10 | serviceid = "B7dZHQcY78VRVz9l" | |
11 | ||
12 | class fmterror(Exception): | |
13 | pass | |
14 | ||
f99c3f74 | 15 | class autherror(auth.autherror): |
8e60b2da FT |
16 | pass |
17 | ||
f78b790d FT |
18 | class 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 | 32 | def 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 | ||
52 | def linkurl(ln): | |
53 | if ln[0] != '/': | |
54 | raise fmterror("unexpected link url: " + ln) | |
55 | return parse.urljoin(apibase, ln[1:]) | |
56 | ||
57 | def 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 | ||
67 | def base64(data): | |
68 | return binascii.b2a_base64(data).decode("ascii").strip().rstrip("=") | |
69 | ||
717d54fc | 70 | class 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 | 86 | class 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 | 122 | class 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 | 140 | class 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 | 174 | class 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()) |