| 1 | import json, http.cookiejar, binascii, time, datetime, pickle, hashlib |
| 2 | from urllib import request, parse |
| 3 | from bs4 import BeautifulSoup as soup |
| 4 | from . import currency, auth |
| 5 | soupify = lambda cont: soup(cont, "html.parser") |
| 6 | |
| 7 | apibase = "https://online.swedbank.se/TDE_DAP_Portal_REST_WEB/api/" |
| 8 | loginurl = "https://online.swedbank.se/app/privat/login" |
| 9 | serviceid = "B7dZHQcY78VRVz9l" |
| 10 | |
| 11 | class fmterror(Exception): |
| 12 | pass |
| 13 | |
| 14 | class autherror(Exception): |
| 15 | pass |
| 16 | |
| 17 | def resolve(d, keys, default=fmterror): |
| 18 | def err(): |
| 19 | if default is fmterror: |
| 20 | raise fmterror() |
| 21 | return default |
| 22 | def rec(d, keys): |
| 23 | if len(keys) == 0: |
| 24 | return d |
| 25 | if isinstance(d, dict): |
| 26 | if keys[0] not in d: |
| 27 | return err() |
| 28 | return rec(d[keys[0]], keys[1:]) |
| 29 | else: |
| 30 | return err() |
| 31 | return rec(d, keys) |
| 32 | |
| 33 | def linkurl(ln): |
| 34 | if ln[0] != '/': |
| 35 | raise fmterror("unexpected link url: " + ln) |
| 36 | return parse.urljoin(apibase, ln[1:]) |
| 37 | |
| 38 | def getdsid(): |
| 39 | with request.urlopen(loginurl) as resp: |
| 40 | if resp.code != 200: |
| 41 | raise fmterror("Unexpected HTTP status code: " + str(resp.code)) |
| 42 | doc = soupify(resp.read()) |
| 43 | dsel = doc.find("div", id="cust-sess-id") |
| 44 | if not dsel or not dsel.has_attr("value"): |
| 45 | raise fmterror("DSID DIV not on login page") |
| 46 | return dsel["value"] |
| 47 | |
| 48 | def base64(data): |
| 49 | return binascii.b2a_base64(data).decode("ascii").strip().rstrip("=") |
| 50 | |
| 51 | class transaction(object): |
| 52 | def __init__(self, account, data): |
| 53 | self.account = account |
| 54 | self._data = data |
| 55 | |
| 56 | _datefmt = "%Y-%m-%d" |
| 57 | |
| 58 | @property |
| 59 | def value(self): return currency.currency.get(resolve(self._data, ("currency",))).parse(resolve(self._data, ("amount",))) |
| 60 | @property |
| 61 | def message(self): return resolve(self._data, ("description",)) |
| 62 | @property |
| 63 | def date(self): |
| 64 | p = time.strptime(resolve(self._data, ("accountingDate",)), self._datefmt) |
| 65 | return datetime.date(p.tm_year, p.tm_mon, p.tm_mday) |
| 66 | |
| 67 | @property |
| 68 | def hash(self): |
| 69 | dig = hashlib.sha256() |
| 70 | dig.update(str(self.date.toordinal()).encode("ascii") + b"\0") |
| 71 | dig.update(self.message.encode("utf-8") + b"\0") |
| 72 | dig.update(str(self.value.amount).encode("ascii") + b"\0") |
| 73 | dig.update(self.value.currency.symbol.encode("ascii") + b"\0") |
| 74 | return dig.hexdigest() |
| 75 | |
| 76 | def __repr__(self): |
| 77 | return "#<fsb.transaction %s: %r>" % (self.value, self.message) |
| 78 | |
| 79 | class account(object): |
| 80 | def __init__(self, sess, id, idata): |
| 81 | self.sess = sess |
| 82 | self.id = id |
| 83 | self._data = None |
| 84 | self._idata = idata |
| 85 | |
| 86 | @property |
| 87 | def data(self): |
| 88 | if self._data is None: |
| 89 | self._data = self.sess._jreq("v5/engagement/account/" + self.id) |
| 90 | return self._data |
| 91 | |
| 92 | @property |
| 93 | def number(self): return resolve(self.data, ("accountNumber",)) |
| 94 | @property |
| 95 | def clearing(self): return resolve(self.data, ("clearingNumber",)) |
| 96 | @property |
| 97 | def fullnumber(self): return resolve(self.data, ("fullyFormattedNumber",)) |
| 98 | @property |
| 99 | def balance(self): return currency.currency.get(resolve(self.data, ("balance", "currencyCode"))).parse(resolve(self.data, ("balance", "amount"))) |
| 100 | @property |
| 101 | def name(self): return resolve(self._idata, ("name",)) |
| 102 | |
| 103 | def transactions(self): |
| 104 | pagesz = 50 |
| 105 | page = 1 |
| 106 | while True: |
| 107 | data = self.sess._jreq("v5/engagement/transactions/" + self.id, transactionsPerPage=pagesz, page=page) |
| 108 | txlist = resolve(data, ("transactions",)) |
| 109 | if len(txlist) < 1: |
| 110 | break |
| 111 | for tx in txlist: |
| 112 | yield transaction(self, tx) |
| 113 | page += 1 |
| 114 | |
| 115 | def __repr__(self): |
| 116 | return "#<fsb.account %s: %r>" % (self.fullnumber, self.name) |
| 117 | |
| 118 | class session(object): |
| 119 | def __init__(self, dsid): |
| 120 | self.dsid = dsid |
| 121 | self.auth = base64((serviceid + ":" + str(int(time.time() * 1000))).encode("ascii")) |
| 122 | self.jar = request.HTTPCookieProcessor() |
| 123 | self.jar.cookiejar.set_cookie(http.cookiejar.Cookie( |
| 124 | version=0, name="dsid", value=dsid, path="/", path_specified=True, |
| 125 | domain=".online.swedbank.se", domain_specified=True, domain_initial_dot=True, |
| 126 | port=None, port_specified=False, secure=False, expires=None, |
| 127 | discard=True, comment=None, comment_url=None, |
| 128 | rest={}, rfc2109=False)) |
| 129 | self.userid = None |
| 130 | self._accounts = None |
| 131 | |
| 132 | def _req(self, url, data=None, ctype=None, headers={}, method=None, **kws): |
| 133 | if "dsid" not in kws: |
| 134 | kws["dsid"] = self.dsid |
| 135 | kws = {k: v for (k, v) in kws.items() if v is not None} |
| 136 | url = parse.urljoin(apibase, url + "?" + parse.urlencode(kws)) |
| 137 | if isinstance(data, dict): |
| 138 | data = json.dumps(data).encode("utf-8") |
| 139 | ctype = "application/json;charset=UTF-8" |
| 140 | req = request.Request(url, data=data, method=method) |
| 141 | for hnam, hval in headers.items(): |
| 142 | req.add_header(hnam, hval) |
| 143 | if ctype is not None: |
| 144 | req.add_header("Content-Type", ctype) |
| 145 | req.add_header("Authorization", self.auth) |
| 146 | self.jar.https_request(req) |
| 147 | with request.urlopen(req) as resp: |
| 148 | if resp.code != 200 and resp.code != 201: |
| 149 | raise fmterror("Unexpected HTTP status code: " + str(resp.code)) |
| 150 | self.jar.https_response(req, resp) |
| 151 | return resp.read() |
| 152 | |
| 153 | def _jreq(self, *args, **kwargs): |
| 154 | headers = kwargs.pop("headers", {}) |
| 155 | headers["Accept"] = "application/json" |
| 156 | ret = self._req(*args, headers=headers, **kwargs) |
| 157 | return json.loads(ret.decode("utf-8")) |
| 158 | |
| 159 | def _postlogin(self): |
| 160 | auth = self._jreq("v5/user/authenticationinfo") |
| 161 | uid = auth.get("identifiedUser", "") |
| 162 | if uid == "": |
| 163 | raise fmterror("no identified user even after successful authentication") |
| 164 | self.userid = uid |
| 165 | prof = self._jreq("v5/profile/") |
| 166 | if len(prof["banks"]) != 1: |
| 167 | raise fmterror("do not know the meaning of multiple banks") |
| 168 | rolesw = linkurl(resolve(prof["banks"][0], ("privateProfile", "links", "next", "uri"))) |
| 169 | self._jreq(rolesw, method="POST") |
| 170 | |
| 171 | def auth_bankid(self, user, conv=None): |
| 172 | if conv is None: |
| 173 | conv = auth.default() |
| 174 | data = self._jreq("v5/identification/bankid/mobile", data = { |
| 175 | "userId": user, |
| 176 | "useEasyLogin": False, |
| 177 | "generateEasyLoginId": False}) |
| 178 | if data.get("status") != "USER_SIGN": |
| 179 | raise fmterror("unexpected bankid status: " + str(data.get("status"))) |
| 180 | vfy = linkurl(resolve(data, ("links", "next", "uri"))) |
| 181 | fst = None |
| 182 | while True: |
| 183 | time.sleep(3) |
| 184 | vdat = self._jreq(vfy) |
| 185 | st = vdat.get("status") |
| 186 | if st in {"USER_SIGN", "CLIENT_NOT_STARTED"}: |
| 187 | if st != fst: |
| 188 | conv.message("Status: %s" % (st,), auth.conv.msg_info) |
| 189 | fst = st |
| 190 | continue |
| 191 | elif st == "COMPLETE": |
| 192 | self._postlogin() |
| 193 | return |
| 194 | elif st == "CANCELLED": |
| 195 | raise autherror("authentication cancelled") |
| 196 | else: |
| 197 | raise fmterror("unexpected bankid status: " + str(st)) |
| 198 | |
| 199 | def keepalive(self): |
| 200 | data = self._jreq("v5/framework/clientsession") |
| 201 | return data["timeoutInMillis"] / 1000 |
| 202 | |
| 203 | @property |
| 204 | def accounts(self): |
| 205 | if self._accounts is None: |
| 206 | data = self._jreq("v5/engagement/overview") |
| 207 | accounts = [] |
| 208 | for acct in resolve(data, ("transactionAccounts",)): |
| 209 | accounts.append(account(self, resolve(acct, ("id",)), acct)) |
| 210 | self._accounts = accounts |
| 211 | return self._accounts |
| 212 | |
| 213 | def logout(self): |
| 214 | if self.userid is not None: |
| 215 | self._jreq("v5/identification/logout", method="PUT") |
| 216 | self.userid = None |
| 217 | |
| 218 | def close(self): |
| 219 | self.logout() |
| 220 | self._req("v5/framework/clientsession", method="DELETE") |
| 221 | |
| 222 | def __enter__(self): |
| 223 | return self |
| 224 | |
| 225 | def __exit__(self, *excinfo): |
| 226 | self.close() |
| 227 | return False |
| 228 | |
| 229 | def __repr__(self): |
| 230 | if self.userid is not None: |
| 231 | return "#<fsb.session %s>" % self.userid |
| 232 | return "#<fsb.session>" |
| 233 | |
| 234 | @classmethod |
| 235 | def create(cls): |
| 236 | return cls(getdsid()) |
| 237 | |
| 238 | def save(self, filename): |
| 239 | with open(filename, "wb") as fp: |
| 240 | pickle.dump(self, fp) |
| 241 | |
| 242 | @classmethod |
| 243 | def load(cls, filename): |
| 244 | with open(filename, "rb") as fp: |
| 245 | return pickle.load(fp) |