From c6f77f8a991ab7821336a1bf39c2215784422cd3 Mon Sep 17 00:00:00 2001 From: Dhruvin Shah <33428164+dhruvinsh@users.noreply.github.com> Date: Sun, 25 Aug 2024 21:43:56 -0400 Subject: [PATCH] feat(2FA): support added for windscribe (#20) * feat(env): adding pyotp for totp support * feat(2fa): support added for windscribe login #19 * docs: updated with 2fa information --- .env.example | 1 + README.md | 18 ++++++++++-------- poetry.lock | 16 +++++++++++++++- pyproject.toml | 1 + requirements.txt | 3 +++ src/config.py | 2 ++ src/run.py | 5 ++++- src/ws/ws.py | 13 ++++++++++--- 8 files changed, 46 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 3db50f1..23a0a21 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ WS_USERNAME=xxxx WS_PASSWORD=xxxx +WS_TOTP=xxxxxxxx WS_DEBUG=False WS_COOKIE_PATH=/some/mounted/volume/ QBIT_USERNAME=xxxx diff --git a/README.md b/README.md index a668c69..79ec224 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ docker run \ -e WS_DEBUG=False \ -e WS_PASSWORD=password \ -e WS_USERNAME=username \ +-e WS_TOPT=totp_token \ -v /path/to/local/data:/cookie \ dhruvinsh/ws-ephemeral:latest ``` @@ -64,6 +65,7 @@ docker compose up -d | -------------------- | -------------------------------------------------------------------------------- | | WS_USERNAME | WS username | | WS_PASSWORD | WS password | +| WS_TOTP | WS totp token for 2fa | | WS_DEBUG | Enable Debug logging | | WS_COOKIE_PATH | Persistent location for the cookie. (v3.x.x only) | | QBIT_USERNAME | QBIT username | @@ -93,14 +95,14 @@ or concerns, please open an issue here. ## Roadmap -- [] Support 2FA -- [] Daemon mode and job mode - - [] Rest API (useful for cron/script job) - - [] Separate port renewal, qbittorrent update and private tracker logic - - [] Random job time for cron job #15 -- [] Allow to run custom script (for now Bash script only) #12 -- [] Support for deluge -- [] Gluetun support [#2392](https://github.com/qdm12/gluetun/pull/2392) +- [x] Support 2FA, #19 +- [ ] Daemon mode and job mode + - [ ] Rest API (useful for cron/script job) + - [ ] Separate port renewal, qbittorrent update and private tracker logic + - [ ] Random job time for cron job #15 +- [ ] Allow to run custom script (for now Bash script only) #12 +- [ ] Support for deluge +- [ ] Gluetun support [#2392](https://github.com/qdm12/gluetun/pull/2392) ## License diff --git a/poetry.lock b/poetry.lock index 0e1931d..469a815 100644 --- a/poetry.lock +++ b/poetry.lock @@ -439,6 +439,20 @@ tomlkit = ">=0.10.1" spelling = ["pyenchant (>=3.2,<4.0)"] testutils = ["gitpython (>3)"] +[[package]] +name = "pyotp" +version = "2.9.0" +description = "Python One Time Password Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612"}, + {file = "pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63"}, +] + +[package.extras] +test = ["coverage", "mypy", "ruff", "wheel"] + [[package]] name = "pyyaml" version = "6.0.2" @@ -661,4 +675,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "c8121a5b46092aee4f1d4aac74fe22e14fb309d87c5cb31ab988a9b4ee2c0a35" +content-hash = "6a160a635a14cba748e287d09e36603afb39cc674aa6a12674904752116564a4" diff --git a/pyproject.toml b/pyproject.toml index dc2ab5a..c2b19ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ tqdm = "^4.65.0" pyyaml = "^6.0" semver = "^3.0.0" schedule = "^1.2.0" +pyotp = "^2.9.0" [tool.poetry.group.dev.dependencies] tqdm-stubs = ">=0.2.1" diff --git a/requirements.txt b/requirements.txt index 8ac5e49..002af00 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,6 +113,9 @@ idna==3.8 ; python_version >= "3.11" and python_version < "4.0" \ packaging==24.1 ; python_version >= "3.11" and python_version < "4.0" \ --hash=sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002 \ --hash=sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124 +pyotp==2.9.0 ; python_version >= "3.11" and python_version < "4.0" \ + --hash=sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63 \ + --hash=sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612 pyyaml==6.0.2 ; python_version >= "3.11" and python_version < "4.0" \ --hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \ --hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \ diff --git a/src/config.py b/src/config.py index 3faeb3a..f180c87 100644 --- a/src/config.py +++ b/src/config.py @@ -1,6 +1,7 @@ """ config module """ + import os import sys from pathlib import Path @@ -32,6 +33,7 @@ # WS config WS_USERNAME: str = os.getenv("WS_USERNAME", "") WS_PASSWORD: str = os.getenv("WS_PASSWORD", "") +WS_TOTP: str | None = os.getenv("WS_TOTP", None) WS_COOKIE = Path(os.getenv("WS_COOKIE_PATH", ".")) / "cookie.pkl" if not all([WS_USERNAME, WS_PASSWORD]): diff --git a/src/run.py b/src/run.py index 96ed6fb..7a13416 100644 --- a/src/run.py +++ b/src/run.py @@ -1,6 +1,7 @@ """ Module that run the setup for windscrib's ephemeral port """ + import logging import time @@ -38,7 +39,9 @@ def main() -> None: return logger.info("Running automation...") - with Windscribe(username=config.WS_USERNAME, password=config.WS_PASSWORD) as ws: + with Windscribe( + username=config.WS_USERNAME, password=config.WS_PASSWORD, totp=config.WS_TOTP + ) as ws: port = ws.setup() if not config.QBIT_FOUND: diff --git a/src/ws/ws.py b/src/ws/ws.py index a12f5eb..a3f81df 100644 --- a/src/ws/ws.py +++ b/src/ws/ws.py @@ -12,6 +12,7 @@ from typing import TypedDict, Union import httpx +import pyotp import config from lib.decorators import login_required @@ -33,7 +34,7 @@ class Windscribe: """ # pylint: disable=redefined-outer-name - def __init__(self, username: str, password: str) -> None: + def __init__(self, username: str, password: str, totp: str | None = None) -> None: headers = { "origin": config.BASE_URL, "referer": config.LOGIN_URL, @@ -54,6 +55,7 @@ def __init__(self, username: str, password: str) -> None: self.csrf: Csrf = self.get_csrf() self.username = username self.password = password + self.totp = totp self.logger = logging.getLogger(self.__class__.__name__) @@ -102,6 +104,11 @@ def renew_csrf(self) -> Csrf: def login(self) -> None: """login in to the webpage.""" + # NOTE: at the given moment try to resolve totp so that we don't have any delay. + totp = "" + if self.totp is not None: + totp = pyotp.TOTP(self.totp).now() + data = { "login": 1, "upgrade": 0, @@ -109,9 +116,9 @@ def login(self) -> None: "csrf_token": self.csrf["csrf_token"], "username": self.username, "password": self.password, - "code": "", + "code": totp, } - self.client.post(config.LOGIN_URL, data=data) + _ = self.client.post(config.LOGIN_URL, data=data) # save the cookie for the future use. save_cookie(self.client.cookies)