Skip to content

Commit 91ee8f1

Browse files
Replace username/password login with REVEL_SESSION cookie-based authentication to handle AtCoder CAPTCHA (#310)
* Replace username/password login with REVEL_SESSION cookie-based authentication to handle AtCoder CAPTCHA
1 parent ed42453 commit 91ee8f1

File tree

8 files changed

+181
-63
lines changed

8 files changed

+181
-63
lines changed

README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,22 @@ https://kyuridenamida.github.io/atcoder-tools/
6262
## Usage
6363

6464

65-
*重要: かつてパスワード入力なしでログインを実現するために`AccountInformation.py`にログイン情報を書き込むことを要求していましたが、セキュリティリスクが高すぎるため、セッション情報のみを保持する方針に切り替えました。
66-
今後はできるだけ保持されているセッション情報を利用してAtCoderにアクセスし、必要に応じて再入力を要求します。
67-
過去のユーザーの皆様には`AccountInformation.py`を削除して頂くようお願い申し上げます。*
65+
*重要: AtCoderにCAPTCHAが導入されたため、従来のユーザー名/パスワードによる自動ログインが困難になりました。
66+
現在は**ブラウザのREVEL_SESSIONクッキーを使用したログイン方式**を採用しています。
67+
初回実行時にクッキー取得手順が表示されますので、ブラウザでAtCoderにログイン後、開発者ツールからREVEL_SESSIONクッキーの値をコピーして入力してください。
68+
一度設定すれば、クッキーの有効期限まで自動的にログイン状態が維持されます。*
69+
70+
### ログイン方法 (REVEL_SESSIONクッキー)
71+
72+
1. ブラウザでAtCoderにログインします: https://atcoder.jp/login
73+
2. F12キーを押して開発者ツールを開きます
74+
3. 「Application」タブ(Firefoxの場合は「Storage」タブ)をクリック
75+
4. 左側から「Cookies」→「https://atcoder.jp」を選択
76+
5. 「REVEL_SESSION」の「Value」列の値をコピー
77+
6. atcoder-toolsの実行時に表示されるプロンプトに貼り付け
78+
79+
このクッキー値はローカルに保存され、次回以降の自動ログインに使用されます。ユーザー名・パスワードこそ流出しないものの、この値を悪用されると第三者があなたのアカウントにログインできてしまいます。
80+
流出しないように管理してください。LICENSEにもあるように、このツールを利用したことによるいかなる不利益に対してもatcoder-toolsの開発者は一切責任を負いません。
6881

6982

7083
- `atcoder-tools gen {contest_id}` コンテスト環境を用意します。

atcodertools/client/atcoder.py

Lines changed: 87 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import getpass
21
import os
32
import re
43
import warnings
54
from http.cookiejar import LWPCookieJar
6-
from typing import List, Optional, Tuple, Union
5+
from typing import List, Optional, Union
6+
from http.cookiejar import Cookie
77

88
import requests
99
from bs4 import BeautifulSoup
@@ -61,10 +61,62 @@ def __call__(cls, *args, **kwargs):
6161
return cls._instances[cls]
6262

6363

64-
def default_credential_supplier() -> Tuple[str, str]:
65-
username = input('AtCoder username: ')
66-
password = getpass.getpass('AtCoder password: ')
67-
return username, password
64+
def show_cookie_instructions():
65+
"""Show instructions for obtaining REVEL_SESSION cookie"""
66+
print("\n[English]")
67+
print("=== How to get AtCoder REVEL_SESSION cookie ===")
68+
print("1. Log in to AtCoder using your browser")
69+
print(" https://atcoder.jp/login")
70+
print()
71+
print("2. Open Developer Tools by pressing F12")
72+
print()
73+
print("3. Follow these steps to get the REVEL_SESSION cookie:")
74+
print(" - Click the 'Application' tab (or 'Storage' tab in Firefox)")
75+
print(" - Select 'Cookies' → 'https://atcoder.jp' from the left sidebar")
76+
print(" - Find 'REVEL_SESSION' and copy its 'Value'")
77+
print()
78+
print("4. Paste the REVEL_SESSION value below")
79+
print("=" * 48)
80+
print()
81+
print("[日本語/Japanese]")
82+
print("=== AtCoder REVEL_SESSION クッキーの取得方法 ===")
83+
print("1. ブラウザでAtCoderにログインしてください")
84+
print(" https://atcoder.jp/login")
85+
print()
86+
print("2. F12キーを押して開発者ツールを開いてください")
87+
print()
88+
print("3. 以下の手順でREVEL_SESSIONクッキーを取得してください:")
89+
print(" - 「Application」タブ(Firefoxの場合は「Storage」タブ)をクリック")
90+
print(" - 左側から「Cookies」→「https://atcoder.jp」を選択")
91+
print(" - 「REVEL_SESSION」の「Value」列の値をコピー")
92+
print()
93+
print("4. 下記にREVEL_SESSIONの値を貼り付けてください")
94+
print("=" * 48)
95+
96+
97+
def default_cookie_supplier() -> Cookie:
98+
"""Get REVEL_SESSION cookie from user input and return Cookie object"""
99+
show_cookie_instructions()
100+
cookie_value = input('\nREVEL_SESSION cookie value: ').strip()
101+
return Cookie(
102+
version=0,
103+
name='REVEL_SESSION',
104+
value=cookie_value,
105+
port=None,
106+
port_specified=False,
107+
domain='atcoder.jp',
108+
domain_specified=True,
109+
domain_initial_dot=False,
110+
path='/',
111+
path_specified=True,
112+
secure=True,
113+
expires=None,
114+
discard=True,
115+
comment=None,
116+
comment_url=None,
117+
rest={},
118+
rfc2109=False
119+
)
68120

69121

70122
class AtCoderClient(metaclass=Singleton):
@@ -75,40 +127,39 @@ def __init__(self):
75127
def check_logging_in(self):
76128
private_url = "https://atcoder.jp/home"
77129
resp = self._request(private_url)
78-
return resp.text.find("Sign In") == -1
130+
# consider logged in if settings link exists (only shown when logged in)
131+
return 'href="/settings"' in resp.text
79132

80133
def login(self,
81-
credential_supplier=None,
134+
cookie_supplier=None,
82135
use_local_session_cache=True,
83136
save_session_cache=True):
84137

85-
if credential_supplier is None:
86-
credential_supplier = default_credential_supplier
87-
88138
if use_local_session_cache:
89-
load_cookie_to(self._session)
90-
if self.check_logging_in():
91-
logger.info(
92-
"Successfully Logged in using the previous session cache.")
93-
logger.info(
94-
"If you'd like to invalidate the cache, delete {}.".format(default_cookie_path))
95-
96-
return
97-
98-
username, password = credential_supplier()
99-
100-
soup = BeautifulSoup(self._session.get(
101-
"https://atcoder.jp/login").text, "html.parser")
102-
token = soup.find_all("form")[1].find(
103-
"input", type="hidden").get("value")
104-
resp = self._request("https://atcoder.jp/login", data={
105-
'username': username,
106-
"password": password,
107-
"csrf_token": token
108-
}, method='POST')
109-
110-
if resp.text.find("パスワードを忘れた方はこちら") != -1 or resp.text.find("Forgot your password") != -1:
111-
raise LoginError
139+
session_cache_exists = load_cookie_to(self._session)
140+
if session_cache_exists:
141+
if self.check_logging_in():
142+
logger.info(
143+
"Successfully Logged in using the previous session cache.")
144+
logger.info(
145+
"If you'd like to invalidate the cache, delete {}.".format(default_cookie_path))
146+
return
147+
else:
148+
logger.warn(
149+
"Failed to login with the session cache. The session cache is invalid, or has been expired. Trying to login without cache.")
150+
151+
if cookie_supplier is None:
152+
cookie_supplier = default_cookie_supplier
153+
154+
cookie = cookie_supplier()
155+
self._session.cookies.set_cookie(cookie)
156+
157+
# Verify the cookie is valid
158+
if not self.check_logging_in():
159+
raise LoginError(
160+
"Login attempt failed. REVEL_SESSION cookie could be invalid or expired.")
161+
else:
162+
logger.info("Successfully logged in using REVEL_SESSION cookie.")
112163

113164
if use_local_session_cache and save_session_cache:
114165
save_cookie(self._session)
@@ -159,7 +210,8 @@ def download_all_contests(self) -> List[Contest]:
159210
contest_ids = sorted(contest_ids)
160211
return [Contest(contest_id) for contest_id in contest_ids]
161212

162-
def submit_source_code(self, contest: Contest, problem: Problem, lang: Union[str, Language], source: str) -> Submission:
213+
def submit_source_code(self, contest: Contest, problem: Problem, lang: Union[str, Language],
214+
source: str) -> Submission:
163215
if isinstance(lang, str):
164216
warnings.warn(
165217
"Parameter lang as a str object is deprecated. "

atcodertools/tools/codegen.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,8 @@ def main(prog, args, output_file=sys.stdout):
169169
client.login(
170170
save_session_cache=not config.etc_config.save_no_session_cache)
171171
logger.info("Login successful.")
172-
except LoginError:
173-
logger.error(
174-
"Failed to login (maybe due to wrong username/password combination?)")
172+
except LoginError as e:
173+
logger.error(e)
175174
sys.exit(-1)
176175
else:
177176
logger.info("Downloading data without login.")

atcodertools/tools/envgen.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -317,9 +317,8 @@ def main(prog, args):
317317
client.login(
318318
save_session_cache=not config.etc_config.save_no_session_cache)
319319
logger.info("Login successful.")
320-
except LoginError:
321-
logger.error(
322-
"Failed to login (maybe due to wrong username/password combination?)")
320+
except LoginError as e:
321+
logger.error(e)
323322
sys.exit(-1)
324323
else:
325324
logger.info("Downloading data without login.")

atcodertools/tools/submit.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/python3
22
import argparse
3-
import sys
43
import os
4+
import sys
55

66
from colorama import Fore
77

@@ -18,7 +18,7 @@
1818
from atcodertools.executils.run_command import run_command
1919

2020

21-
def main(prog, args, credential_supplier=None, use_local_session_cache=True, client=None) -> bool:
21+
def main(prog, args, cookie_supplier=None, use_local_session_cache=True, client=None) -> bool:
2222
parser = argparse.ArgumentParser(
2323
prog=prog,
2424
formatter_class=argparse.RawTextHelpFormatter)
@@ -121,11 +121,11 @@ def main(prog, args, credential_supplier=None, use_local_session_cache=True, cli
121121
try:
122122
client = AtCoderClient()
123123
client.login(save_session_cache=not args.save_no_session_cache,
124-
credential_supplier=credential_supplier,
124+
cookie_supplier=cookie_supplier,
125125
use_local_session_cache=use_local_session_cache,
126126
)
127-
except LoginError:
128-
logger.error("Login failed. Try again.")
127+
except LoginError as e:
128+
logger.error(e)
129129
return False
130130

131131
tester_args = []

tests/test_atcoder_client_mock.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,15 +110,32 @@ def test_submit_source_code_with_captcha_html_file(self):
110110
@restore_client_after_run
111111
def test_login_success(self):
112112
self.client._request = create_fake_request_func(
113-
post_url_to_resp={
114-
"https://atcoder.jp/login": fake_resp("after_login.html")
115-
}
113+
{"https://atcoder.jp/home": fake_resp("after_login.html")},
116114
)
117115

118-
def fake_supplier():
119-
return "@@@ invalid user name @@@", "@@@ password @@@"
120-
121-
self.client.login(credential_supplier=fake_supplier,
116+
def fake_cookie_supplier():
117+
from http.cookiejar import Cookie
118+
return Cookie(
119+
version=0,
120+
name='REVEL_SESSION',
121+
value="@@@ invalid cookie @@@",
122+
port=None,
123+
port_specified=False,
124+
domain='atcoder.jp',
125+
domain_specified=True,
126+
domain_initial_dot=False,
127+
path='/',
128+
path_specified=True,
129+
secure=True,
130+
expires=None,
131+
discard=True,
132+
comment=None,
133+
comment_url=None,
134+
rest={},
135+
rfc2109=False
136+
)
137+
138+
self.client.login(cookie_supplier=fake_cookie_supplier,
122139
use_local_session_cache=False)
123140

124141
@restore_client_after_run

tests/test_atcoder_client_real.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,30 @@ def test_download_problem_content(self):
5151

5252
@retry_once_on_failure
5353
def test_login_failed(self):
54-
def fake_supplier():
55-
return "@@@ invalid user name @@@", "@@@ password @@@"
54+
def fake_cookie_supplier():
55+
from http.cookiejar import Cookie
56+
return Cookie(
57+
version=0,
58+
name='REVEL_SESSION',
59+
value="@@@ invalid cookie @@@",
60+
port=None,
61+
port_specified=False,
62+
domain='atcoder.jp',
63+
domain_specified=True,
64+
domain_initial_dot=False,
65+
path='/',
66+
path_specified=True,
67+
secure=True,
68+
expires=None,
69+
discard=True,
70+
comment=None,
71+
comment_url=None,
72+
rest={},
73+
rfc2109=False
74+
)
5675

5776
try:
58-
self.client.login(credential_supplier=fake_supplier,
77+
self.client.login(cookie_supplier=fake_cookie_supplier,
5978
use_local_session_cache=False)
6079
self.fail("Unexpectedly, this test succeeded to login.")
6180
except LoginError:

tests/test_submit.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,39 @@
77
"./resources/test_submit/"))
88

99

10-
def fake_credential_suplier():
11-
return "@@fakeuser@@", "fakepass"
10+
def fake_cookie_supplier():
11+
from http.cookiejar import Cookie
12+
return Cookie(
13+
version=0,
14+
name='REVEL_SESSION',
15+
value="@@@ invalid cookie @@@",
16+
port=None,
17+
port_specified=False,
18+
domain='atcoder.jp',
19+
domain_specified=True,
20+
domain_initial_dot=False,
21+
path='/',
22+
path_specified=True,
23+
secure=True,
24+
expires=None,
25+
discard=True,
26+
comment=None,
27+
comment_url=None,
28+
rest={},
29+
rfc2109=False
30+
)
1231

1332

1433
class TestTester(unittest.TestCase):
1534

1635
def test_submit_fail_when_metadata_not_found(self):
1736
ok = submit.main(
18-
'', ['-d', os.path.join(RESOURCE_DIR, "without_metadata")], fake_credential_suplier, False)
37+
'', ['-d', os.path.join(RESOURCE_DIR, "without_metadata")], fake_cookie_supplier, False)
1938
self.assertFalse(ok)
2039

2140
def test_test_fail(self):
2241
ok = submit.main(
23-
'', ['-d', os.path.join(RESOURCE_DIR, "with_metadata")], fake_credential_suplier, False)
42+
'', ['-d', os.path.join(RESOURCE_DIR, "with_metadata")], fake_cookie_supplier, False)
2443
self.assertFalse(ok)
2544

2645

0 commit comments

Comments
 (0)