From 9143acafd1c61b7beee868239c23848cce92638a Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 22 Jun 2022 05:00:43 +0000 Subject: [PATCH] Access captcha --- anonstream/__init__.py | 3 +- anonstream/access.py | 18 ++++++++ anonstream/config.py | 1 + anonstream/routes/core.py | 64 +++++++++++++++++++++++----- anonstream/routes/wrappers.py | 71 ++++++++++++++++++++----------- anonstream/templates/captcha.html | 60 ++++++++++++++++++++++++++ config.toml | 3 ++ 7 files changed, 182 insertions(+), 38 deletions(-) create mode 100644 anonstream/access.py create mode 100644 anonstream/templates/captcha.html diff --git a/anonstream/__init__.py b/anonstream/__init__.py index 09bb394..1e4a915 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -38,6 +38,8 @@ def create_app(toml_config): app.captcha_factory = create_captcha_factory(app.config['CAPTCHA_FONTS']) app.captcha_signer = create_captcha_signer(app.config['SECRET_KEY']) + app.failures = {} + # State for tasks app.users_update_buffer = set() app.stream_title = None @@ -77,7 +79,6 @@ def create_app(toml_config): ) app.add_background_task(start_event_server) - # Create routes and background tasks import anonstream.routes import anonstream.tasks diff --git a/anonstream/access.py b/anonstream/access.py new file mode 100644 index 0000000..822acd2 --- /dev/null +++ b/anonstream/access.py @@ -0,0 +1,18 @@ +import time + +from quart import current_app + +FAILURES = current_app.failures + +def add_failure(message): + timestamp = time.time_ns() // 1_000_000 + while timestamp in FAILURES: + timestamp += 1 + FAILURES[timestamp] = message + return timestamp + +def pop_failure(failure_id): + try: + return FAILURES.pop(failure_id) + except KeyError: + return None diff --git a/anonstream/config.py b/anonstream/config.py index 376505d..0c5c580 100644 --- a/anonstream/config.py +++ b/anonstream/config.py @@ -19,6 +19,7 @@ def update_flask_from_toml(toml_config, flask_config): 'AUTH_USERNAME': toml_config['auth']['username'], 'AUTH_PWHASH': auth_pwhash, 'AUTH_TOKEN': generate_token(), + 'ACCESS_CAPTCHA': toml_config['access']['captcha'], }) for flask_section in toml_to_flask_sections(toml_config): flask_config.update(flask_section) diff --git a/anonstream/routes/core.py b/anonstream/routes/core.py index f6ba9dc..73637c5 100644 --- a/anonstream/routes/core.py +++ b/anonstream/routes/core.py @@ -6,23 +6,38 @@ import math from quart import current_app, request, render_template, abort, make_response, redirect, url_for, abort, send_from_directory from werkzeug.exceptions import TooManyRequests -from anonstream.captcha import get_captcha_image +from anonstream.access import add_failure, pop_failure +from anonstream.captcha import get_captcha_image, get_random_captcha_digest from anonstream.segments import segments, StopSendingSegments from anonstream.stream import is_online, get_stream_uptime from anonstream.user import watching, create_eyes, renew_eyes, EyesException, RatelimitedEyes -from anonstream.routes.wrappers import with_user_from, auth_required, clean_cache_headers +from anonstream.routes.wrappers import with_user_from, auth_required, clean_cache_headers, generate_and_add_user +from anonstream.helpers.captcha import check_captcha_digest, Answer from anonstream.utils.security import generate_csp +CAPTCHA_SIGNER = current_app.captcha_signer STATIC_DIRECTORY = current_app.root_path / 'static' @current_app.route('/') -@with_user_from(request) -async def home(timestamp, user): - return await render_template( - 'home.html', - csp=generate_csp(), - user=user, - ) +@with_user_from(request, fallback_to_token=True) +async def home(timestamp, user_or_token): + match user_or_token: + case str() | None: + failure_id = request.args.get('failure', type=int) + response = await render_template( + 'captcha.html', + csp=generate_csp(), + token=user_or_token, + digest=get_random_captcha_digest(), + failure=pop_failure(failure_id), + ) + case dict(): + response = await render_template( + 'home.html', + csp=generate_csp(), + user=user_or_token, + ) + return response @current_app.route('/stream.mp4') @with_user_from(request) @@ -58,8 +73,8 @@ async def login(): return redirect(url_for('home'), 303) @current_app.route('/captcha.jpg') -@with_user_from(request) -async def captcha(timestamp, user): +@with_user_from(request, fallback_to_token=True) +async def captcha(timestamp, user_or_token): digest = request.args.get('digest', '') image = get_captcha_image(digest) if image is None: @@ -67,6 +82,33 @@ async def captcha(timestamp, user): else: return image, {'Content-Type': 'image/jpeg'} +@current_app.post('/access') +@with_user_from(request, fallback_to_token=True) +async def access(timestamp, user_or_token): + match user_or_token: + case str() | None: + token = user_or_token + form = await request.form + digest = form.get('digest', '') + answer = form.get('answer', '') + match check_captcha_digest(CAPTCHA_SIGNER, digest, answer): + case Answer.MISSING: + failure_id = add_failure('Captcha is required') + case Answer.BAD: + failure_id = add_failure('Captcha was incorrect') + case Answer.EXPIRED: + failure_id = add_failure('Captcha has expired') + case Answer.OK: + failure_id = None + user = generate_and_add_user(timestamp, token) + if failure_id is not None: + url = url_for('home', token=token, failure=failure_id) + raise abort(redirect(url, 303)) + case dict(): + user = user_or_token + url = url_for('home', token=user['token']) + return redirect(url, 303) + @current_app.route('/static/') @with_user_from(request) @clean_cache_headers diff --git a/anonstream/routes/wrappers.py b/anonstream/routes/wrappers.py index 9a93056..a18bd46 100644 --- a/anonstream/routes/wrappers.py +++ b/anonstream/routes/wrappers.py @@ -71,50 +71,69 @@ def auth_required(f): return wrapper -def with_user_from(context): +def generate_and_add_user(timestamp, token=None, broadcaster=False): + token = token or generate_token() + user = generate_user( + timestamp=timestamp, + token=token, + broadcaster=broadcaster, + presence=Presence.NOTWATCHING, + ) + USERS_BY_TOKEN[token] = user + USERS_UPDATE_BUFFER.add(token) + return user + +def with_user_from(context, fallback_to_token=False): def with_user_from_context(f): @wraps(f) async def wrapper(*args, **kwargs): timestamp = get_timestamp() - # Check if broadcaster + # Get token broadcaster = check_auth(context) + token_from_args = context.args.get('token') + token_from_cookie = try_unquote(context.cookies.get('token')) + token_from_context = token_from_args or token_from_cookie if broadcaster: token = CONFIG['AUTH_TOKEN'] + elif CONFIG['ACCESS_CAPTCHA']: + token = token_from_context else: - token = ( - context.args.get('token') - or try_unquote(context.cookies.get('token')) - or generate_token() - ) - if hmac.compare_digest(token, CONFIG['AUTH_TOKEN']): - raise abort(401) + token = token_from_context or generate_token() # Reject invalid tokens - if not RE_TOKEN.fullmatch(token): + if isinstance(token, str) and not RE_TOKEN.fullmatch(token): raise abort(400) - # Update / create user - user = USERS_BY_TOKEN.get(token) - if user is not None: - see(user) - else: - user = generate_user( - timestamp=timestamp, - token=token, - broadcaster=broadcaster, - presence=Presence.NOTWATCHING, - ) - USERS_BY_TOKEN[token] = user + # Only logged in broadcaster may have the broadcaster's token + if ( + not broadcaster + and isinstance(token, str) + and hmac.compare_digest(token, CONFIG['AUTH_TOKEN']) + ): + raise abort(401) - # Add to the users update buffer - USERS_UPDATE_BUFFER.add(token) + # Create response + user = USERS_BY_TOKEN.get(token) + if CONFIG['ACCESS_CAPTCHA'] and not broadcaster: + if user is not None: + user['last']['seen'] = timestamp + response = await f(timestamp, user, *args, **kwargs) + elif fallback_to_token: + #assert not broadcaster + response = await f(timestamp, token, *args, **kwargs) + else: + raise abort(403) + else: + if user is None: + user = generate_and_add_user(timestamp, token, broadcaster) + response = await f(timestamp, user, *args, **kwargs) # Set cookie - response = await f(timestamp, user, *args, **kwargs) - if try_unquote(context.cookies.get('token')) != token: + if token_from_cookie != token: response = await make_response(response) response.headers['Set-Cookie'] = f'token={quote(token)}; path=/' + return response return wrapper diff --git a/anonstream/templates/captcha.html b/anonstream/templates/captcha.html new file mode 100644 index 0000000..6a6058b --- /dev/null +++ b/anonstream/templates/captcha.html @@ -0,0 +1,60 @@ + + + + + + + + + +
+ + + + {% if failure is not none %}

{{ failure }}

{% endif %} +
+ + diff --git a/config.toml b/config.toml index ff4b3ea..95b2d22 100644 --- a/config.toml +++ b/config.toml @@ -24,6 +24,9 @@ stream_initial_buffer = 3 file = "title.txt" file_cache_lifetime = 0.5 +[access] +captcha = true + [captcha] lifetime = 1800 fonts = []