| 1 | import json, http.cookiejar, binascii, time, datetime, pickle, urllib.error, io |
| 2 | from PIL import Image |
| 3 | from urllib import request, parse |
| 4 | from bs4 import BeautifulSoup as soup |
| 5 | from . import currency, auth, data |
| 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 | |
| 15 | class autherror(auth.autherror): |
| 16 | pass |
| 17 | |
| 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 | |
| 32 | def resolve(d, keys, default=fmterror): |
| 33 | def err(key): |
| 34 | if default is fmterror: |
| 35 | raise fmterror(key) |
| 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: |
| 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]) |
| 47 | return rec(d[keys[0]], keys[1:]) |
| 48 | else: |
| 49 | return err(keys[0]) |
| 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 | |
| 70 | class transaction(data.transaction): |
| 71 | def __init__(self, account, data): |
| 72 | self.account = account |
| 73 | self._data = data |
| 74 | |
| 75 | _datefmt = "%Y-%m-%d" |
| 76 | |
| 77 | @property |
| 78 | def value(self): return currency.currency.get(resolve(self._data, ("currency",))).parse(resolve(self._data, ("amount",))) |
| 79 | @property |
| 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 | |
| 86 | class txnaccount(data.txnaccount): |
| 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 |
| 106 | def balance(self): return currency.currency.get(resolve(self.data, ("balance", "currencyCode"))).parse(resolve(self.data, ("balance", "amount"))) |
| 107 | @property |
| 108 | def name(self): return resolve(self._idata, ("name",)) |
| 109 | |
| 110 | def transactions(self): |
| 111 | pagesz = 50 |
| 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 |
| 121 | |
| 122 | class cardtransaction(data.transaction): |
| 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 | |
| 140 | class cardaccount(data.cardaccount): |
| 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 | |
| 174 | class session(object): |
| 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 |
| 186 | self._accounts = None |
| 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: |
| 204 | if resp.code != 200 and resp.code != 201: |
| 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" |
| 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) |
| 217 | return json.loads(ret.decode("utf-8")) |
| 218 | |
| 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 | |
| 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 | |
| 271 | def auth_bankid(self, user, conv=None): |
| 272 | if conv is None: |
| 273 | conv = auth.default() |
| 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 |
| 287 | st = data.get("status") |
| 288 | vfy = linkurl(resolve(data, ("links", "next", "uri"))) |
| 289 | fst = None |
| 290 | while True: |
| 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 |
| 295 | elif st == "COMPLETE": |
| 296 | self._postlogin() |
| 297 | return |
| 298 | elif st == "CANCELLED": |
| 299 | raise autherror("authentication cancelled") |
| 300 | elif st == "OUTSTANDING_TRANSACTION": |
| 301 | raise autherror("another bankid transaction already in progress") |
| 302 | else: |
| 303 | raise fmterror("unexpected bankid status: " + str(st)) |
| 304 | time.sleep(3) |
| 305 | vdat = self._jreq(vfy) |
| 306 | st = vdat.get("status") |
| 307 | |
| 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: |
| 315 | txndata = self._jreq("v5/engagement/overview") |
| 316 | crddata = self._jreq("v5/card/creditcard") |
| 317 | accounts = [] |
| 318 | for acct in resolve(txndata, ("transactionAccounts",)): |
| 319 | accounts.append(txnaccount(self, resolve(acct, ("id",)), acct)) |
| 320 | for acct in resolve(crddata, ("cardAccounts",)): |
| 321 | accounts.append(cardaccount(self, resolve(acct, ("id",)), acct)) |
| 322 | self._accounts = accounts |
| 323 | return self._accounts |
| 324 | |
| 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 | |
| 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 | |
| 346 | def __enter__(self): |
| 347 | return self |
| 348 | |
| 349 | def __exit__(self, *excinfo): |
| 350 | self.close() |
| 351 | return False |
| 352 | |
| 353 | def __repr__(self): |
| 354 | if self.userid is not None: |
| 355 | return "#<fsb.session %s>" % self.userid |
| 356 | return "#<fsb.session>" |
| 357 | |
| 358 | @classmethod |
| 359 | def create(cls): |
| 360 | return cls(getdsid()) |
| 361 | |
| 362 | def save(self, filename): |
| 363 | with open(filename, "wb") as fp: |
| 364 | pickle.dump(self, fp) |
| 365 | |
| 366 | @classmethod |
| 367 | def load(cls, filename): |
| 368 | with open(filename, "rb") as fp: |
| 369 | return pickle.load(fp) |