diff --git a/README.md b/README.md index 6708539f..2bc48332 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,22 @@ https://kyuridenamida.github.io/atcoder-tools/ ## Usage -*重要: かつてパスワード入力なしでログインを実現するために`AccountInformation.py`にログイン情報を書き込むことを要求していましたが、セキュリティリスクが高すぎるため、セッション情報のみを保持する方針に切り替えました。 -今後はできるだけ保持されているセッション情報を利用してAtCoderにアクセスし、必要に応じて再入力を要求します。 -過去のユーザーの皆様には`AccountInformation.py`を削除して頂くようお願い申し上げます。* +*重要: AtCoderにCAPTCHAが導入されたため、従来のユーザー名/パスワードによる自動ログインが困難になりました。 +現在は**ブラウザのREVEL_SESSIONクッキーを使用したログイン方式**を採用しています。 +初回実行時にクッキー取得手順が表示されますので、ブラウザでAtCoderにログイン後、開発者ツールからREVEL_SESSIONクッキーの値をコピーして入力してください。 +一度設定すれば、クッキーの有効期限まで自動的にログイン状態が維持されます。* + +### ログイン方法 (REVEL_SESSIONクッキー) + +1. ブラウザでAtCoderにログインします: https://atcoder.jp/login +2. F12キーを押して開発者ツールを開きます +3. 「Application」タブ(Firefoxの場合は「Storage」タブ)をクリック +4. 左側から「Cookies」→「https://atcoder.jp」を選択 +5. 「REVEL_SESSION」の「Value」列の値をコピー +6. atcoder-toolsの実行時に表示されるプロンプトに貼り付け + +このクッキー値はローカルに保存され、次回以降の自動ログインに使用されます。ユーザー名・パスワードこそ流出しないものの、この値を悪用されると第三者があなたのアカウントにログインできてしまいます。 +流出しないように管理してください。LICENSEにもあるように、このツールを利用したことによるいかなる不利益に対してもatcoder-toolsの開発者は一切責任を負いません。 - `atcoder-tools gen {contest_id}` コンテスト環境を用意します。 diff --git a/atcodertools/client/atcoder.py b/atcodertools/client/atcoder.py index e7612b02..03e43dfa 100644 --- a/atcodertools/client/atcoder.py +++ b/atcodertools/client/atcoder.py @@ -1,9 +1,9 @@ -import getpass import os import re import warnings from http.cookiejar import LWPCookieJar -from typing import List, Optional, Tuple, Union +from typing import List, Optional, Union +from http.cookiejar import Cookie import requests from bs4 import BeautifulSoup @@ -61,10 +61,62 @@ def __call__(cls, *args, **kwargs): return cls._instances[cls] -def default_credential_supplier() -> Tuple[str, str]: - username = input('AtCoder username: ') - password = getpass.getpass('AtCoder password: ') - return username, password +def show_cookie_instructions(): + """Show instructions for obtaining REVEL_SESSION cookie""" + print("\n[English]") + print("=== How to get AtCoder REVEL_SESSION cookie ===") + print("1. Log in to AtCoder using your browser") + print(" https://atcoder.jp/login") + print() + print("2. Open Developer Tools by pressing F12") + print() + print("3. Follow these steps to get the REVEL_SESSION cookie:") + print(" - Click the 'Application' tab (or 'Storage' tab in Firefox)") + print(" - Select 'Cookies' → 'https://atcoder.jp' from the left sidebar") + print(" - Find 'REVEL_SESSION' and copy its 'Value'") + print() + print("4. Paste the REVEL_SESSION value below") + print("=" * 48) + print() + print("[日本語/Japanese]") + print("=== AtCoder REVEL_SESSION クッキーの取得方法 ===") + print("1. ブラウザでAtCoderにログインしてください") + print(" https://atcoder.jp/login") + print() + print("2. F12キーを押して開発者ツールを開いてください") + print() + print("3. 以下の手順でREVEL_SESSIONクッキーを取得してください:") + print(" - 「Application」タブ(Firefoxの場合は「Storage」タブ)をクリック") + print(" - 左側から「Cookies」→「https://atcoder.jp」を選択") + print(" - 「REVEL_SESSION」の「Value」列の値をコピー") + print() + print("4. 下記にREVEL_SESSIONの値を貼り付けてください") + print("=" * 48) + + +def default_cookie_supplier() -> Cookie: + """Get REVEL_SESSION cookie from user input and return Cookie object""" + show_cookie_instructions() + cookie_value = input('\nREVEL_SESSION cookie value: ').strip() + return Cookie( + version=0, + name='REVEL_SESSION', + value=cookie_value, + port=None, + port_specified=False, + domain='atcoder.jp', + domain_specified=True, + domain_initial_dot=False, + path='/', + path_specified=True, + secure=True, + expires=None, + discard=True, + comment=None, + comment_url=None, + rest={}, + rfc2109=False + ) class AtCoderClient(metaclass=Singleton): @@ -75,40 +127,39 @@ def __init__(self): def check_logging_in(self): private_url = "https://atcoder.jp/home" resp = self._request(private_url) - return resp.text.find("Sign In") == -1 + # consider logged in if settings link exists (only shown when logged in) + return 'href="/settings"' in resp.text def login(self, - credential_supplier=None, + cookie_supplier=None, use_local_session_cache=True, save_session_cache=True): - if credential_supplier is None: - credential_supplier = default_credential_supplier - if use_local_session_cache: - load_cookie_to(self._session) - if self.check_logging_in(): - logger.info( - "Successfully Logged in using the previous session cache.") - logger.info( - "If you'd like to invalidate the cache, delete {}.".format(default_cookie_path)) - - return - - username, password = credential_supplier() - - soup = BeautifulSoup(self._session.get( - "https://atcoder.jp/login").text, "html.parser") - token = soup.find_all("form")[1].find( - "input", type="hidden").get("value") - resp = self._request("https://atcoder.jp/login", data={ - 'username': username, - "password": password, - "csrf_token": token - }, method='POST') - - if resp.text.find("パスワードを忘れた方はこちら") != -1 or resp.text.find("Forgot your password") != -1: - raise LoginError + session_cache_exists = load_cookie_to(self._session) + if session_cache_exists: + if self.check_logging_in(): + logger.info( + "Successfully Logged in using the previous session cache.") + logger.info( + "If you'd like to invalidate the cache, delete {}.".format(default_cookie_path)) + return + else: + logger.warn( + "Failed to login with the session cache. The session cache is invalid, or has been expired. Trying to login without cache.") + + if cookie_supplier is None: + cookie_supplier = default_cookie_supplier + + cookie = cookie_supplier() + self._session.cookies.set_cookie(cookie) + + # Verify the cookie is valid + if not self.check_logging_in(): + raise LoginError( + "Login attempt failed. REVEL_SESSION cookie could be invalid or expired.") + else: + logger.info("Successfully logged in using REVEL_SESSION cookie.") if use_local_session_cache and save_session_cache: save_cookie(self._session) @@ -159,7 +210,8 @@ def download_all_contests(self) -> List[Contest]: contest_ids = sorted(contest_ids) return [Contest(contest_id) for contest_id in contest_ids] - def submit_source_code(self, contest: Contest, problem: Problem, lang: Union[str, Language], source: str) -> Submission: + def submit_source_code(self, contest: Contest, problem: Problem, lang: Union[str, Language], + source: str) -> Submission: if isinstance(lang, str): warnings.warn( "Parameter lang as a str object is deprecated. " diff --git a/atcodertools/tools/codegen.py b/atcodertools/tools/codegen.py index 574c88ec..bb6bf89d 100755 --- a/atcodertools/tools/codegen.py +++ b/atcodertools/tools/codegen.py @@ -169,9 +169,8 @@ def main(prog, args, output_file=sys.stdout): client.login( save_session_cache=not config.etc_config.save_no_session_cache) logger.info("Login successful.") - except LoginError: - logger.error( - "Failed to login (maybe due to wrong username/password combination?)") + except LoginError as e: + logger.error(e) sys.exit(-1) else: logger.info("Downloading data without login.") diff --git a/atcodertools/tools/envgen.py b/atcodertools/tools/envgen.py index 8a5eb393..318aa958 100755 --- a/atcodertools/tools/envgen.py +++ b/atcodertools/tools/envgen.py @@ -317,9 +317,8 @@ def main(prog, args): client.login( save_session_cache=not config.etc_config.save_no_session_cache) logger.info("Login successful.") - except LoginError: - logger.error( - "Failed to login (maybe due to wrong username/password combination?)") + except LoginError as e: + logger.error(e) sys.exit(-1) else: logger.info("Downloading data without login.") diff --git a/atcodertools/tools/submit.py b/atcodertools/tools/submit.py index c8316f61..23b7699b 100755 --- a/atcodertools/tools/submit.py +++ b/atcodertools/tools/submit.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 import argparse -import sys import os +import sys from colorama import Fore @@ -18,7 +18,7 @@ from atcodertools.executils.run_command import run_command -def main(prog, args, credential_supplier=None, use_local_session_cache=True, client=None) -> bool: +def main(prog, args, cookie_supplier=None, use_local_session_cache=True, client=None) -> bool: parser = argparse.ArgumentParser( prog=prog, formatter_class=argparse.RawTextHelpFormatter) @@ -121,11 +121,11 @@ def main(prog, args, credential_supplier=None, use_local_session_cache=True, cli try: client = AtCoderClient() client.login(save_session_cache=not args.save_no_session_cache, - credential_supplier=credential_supplier, + cookie_supplier=cookie_supplier, use_local_session_cache=use_local_session_cache, ) - except LoginError: - logger.error("Login failed. Try again.") + except LoginError as e: + logger.error(e) return False tester_args = [] diff --git a/tests/test_atcoder_client_mock.py b/tests/test_atcoder_client_mock.py index 759fb918..e9622ae6 100644 --- a/tests/test_atcoder_client_mock.py +++ b/tests/test_atcoder_client_mock.py @@ -110,15 +110,32 @@ def test_submit_source_code_with_captcha_html_file(self): @restore_client_after_run def test_login_success(self): self.client._request = create_fake_request_func( - post_url_to_resp={ - "https://atcoder.jp/login": fake_resp("after_login.html") - } + {"https://atcoder.jp/home": fake_resp("after_login.html")}, ) - def fake_supplier(): - return "@@@ invalid user name @@@", "@@@ password @@@" - - self.client.login(credential_supplier=fake_supplier, + def fake_cookie_supplier(): + from http.cookiejar import Cookie + return Cookie( + version=0, + name='REVEL_SESSION', + value="@@@ invalid cookie @@@", + port=None, + port_specified=False, + domain='atcoder.jp', + domain_specified=True, + domain_initial_dot=False, + path='/', + path_specified=True, + secure=True, + expires=None, + discard=True, + comment=None, + comment_url=None, + rest={}, + rfc2109=False + ) + + self.client.login(cookie_supplier=fake_cookie_supplier, use_local_session_cache=False) @restore_client_after_run diff --git a/tests/test_atcoder_client_real.py b/tests/test_atcoder_client_real.py index 7448576b..be990df4 100644 --- a/tests/test_atcoder_client_real.py +++ b/tests/test_atcoder_client_real.py @@ -51,11 +51,30 @@ def test_download_problem_content(self): @retry_once_on_failure def test_login_failed(self): - def fake_supplier(): - return "@@@ invalid user name @@@", "@@@ password @@@" + def fake_cookie_supplier(): + from http.cookiejar import Cookie + return Cookie( + version=0, + name='REVEL_SESSION', + value="@@@ invalid cookie @@@", + port=None, + port_specified=False, + domain='atcoder.jp', + domain_specified=True, + domain_initial_dot=False, + path='/', + path_specified=True, + secure=True, + expires=None, + discard=True, + comment=None, + comment_url=None, + rest={}, + rfc2109=False + ) try: - self.client.login(credential_supplier=fake_supplier, + self.client.login(cookie_supplier=fake_cookie_supplier, use_local_session_cache=False) self.fail("Unexpectedly, this test succeeded to login.") except LoginError: diff --git a/tests/test_submit.py b/tests/test_submit.py index 72164e92..74613a58 100755 --- a/tests/test_submit.py +++ b/tests/test_submit.py @@ -7,20 +7,39 @@ "./resources/test_submit/")) -def fake_credential_suplier(): - return "@@fakeuser@@", "fakepass" +def fake_cookie_supplier(): + from http.cookiejar import Cookie + return Cookie( + version=0, + name='REVEL_SESSION', + value="@@@ invalid cookie @@@", + port=None, + port_specified=False, + domain='atcoder.jp', + domain_specified=True, + domain_initial_dot=False, + path='/', + path_specified=True, + secure=True, + expires=None, + discard=True, + comment=None, + comment_url=None, + rest={}, + rfc2109=False + ) class TestTester(unittest.TestCase): def test_submit_fail_when_metadata_not_found(self): ok = submit.main( - '', ['-d', os.path.join(RESOURCE_DIR, "without_metadata")], fake_credential_suplier, False) + '', ['-d', os.path.join(RESOURCE_DIR, "without_metadata")], fake_cookie_supplier, False) self.assertFalse(ok) def test_test_fail(self): ok = submit.main( - '', ['-d', os.path.join(RESOURCE_DIR, "with_metadata")], fake_credential_suplier, False) + '', ['-d', os.path.join(RESOURCE_DIR, "with_metadata")], fake_cookie_supplier, False) self.assertFalse(ok)