From b7313eec22d2ea57d805074d2cf78b225208815a Mon Sep 17 00:00:00 2001 From: n9k Date: Sun, 20 Feb 2022 04:23:32 +0000 Subject: [PATCH] Captchas, require captcha initially, generalize notices to states --- anonstream/__init__.py | 19 +++++-- anonstream/captcha.py | 49 ++++++++++++++++++ anonstream/chat.py | 8 ++- anonstream/helpers/captcha.py | 70 ++++++++++++++++++++++++++ anonstream/helpers/user.py | 5 +- anonstream/routes/core.py | 13 ++++- anonstream/routes/nojs.py | 77 ++++++++++++++++++++++------- anonstream/routes/websocket.py | 2 +- anonstream/templates/nojs_form.html | 33 ++++++++++--- anonstream/user.py | 44 ++++++++++++----- anonstream/utils/captcha.py | 19 +++++++ anonstream/utils/websocket.py | 5 +- anonstream/websocket.py | 34 ++++++++----- config.toml | 27 ++++++---- 14 files changed, 336 insertions(+), 69 deletions(-) create mode 100644 anonstream/captcha.py create mode 100644 anonstream/helpers/captcha.py create mode 100644 anonstream/utils/captcha.py diff --git a/anonstream/__init__.py b/anonstream/__init__.py index d025d7f..b42b3bd 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -5,9 +5,10 @@ from collections import OrderedDict from quart import Quart from werkzeug.security import generate_password_hash -from anonstream.utils.user import generate_token -from anonstream.utils.colour import color_to_colour from anonstream.segments import DirectoryCache +from anonstream.utils.captcha import create_captcha_factory, create_captcha_signer +from anonstream.utils.colour import color_to_colour +from anonstream.utils.user import generate_token def create_app(): with open('config.toml') as fp: @@ -26,7 +27,8 @@ def create_app(): 'AUTH_TOKEN': generate_token(), 'DEFAULT_HOST_NAME': config['names']['broadcaster'], 'DEFAULT_ANON_NAME': config['names']['anonymous'], - 'MAX_NOTICES': config['memory']['notices'], + 'MAX_STATES': config['memory']['states'], + 'MAX_CAPTCHAS': config['memory']['captchas'], 'MAX_CHAT_MESSAGES': config['memory']['chat_messages'], 'MAX_CHAT_SCROLLBACK': config['memory']['chat_scrollback'], 'CHECKUP_PERIOD_USER': config['intervals']['sunset_users'], @@ -39,9 +41,15 @@ def create_app(): 'CHAT_NAME_MAX_LENGTH': config['chat']['max_name_length'], 'CHAT_NAME_MIN_CONTRAST': config['chat']['min_name_contrast'], 'CHAT_BACKGROUND_COLOUR': color_to_colour(config['chat']['background_color']), + 'CAPTCHA_LIFETIME': config['captcha']['lifetime'], + 'CAPTCHA_FONTS': config['captcha']['fonts'], + 'CAPTCHA_ALPHABET': config['captcha']['alphabet'], + 'CAPTCHA_LENGTH': config['captcha']['length'], + 'CAPTCHA_BACKGROUND_COLOUR': color_to_colour(config['captcha']['background_color']), + 'CAPTCHA_FOREGROUND_COLOUR': color_to_colour(config['captcha']['foreground_color']), }) - assert app.config['MAX_NOTICES'] >= 0 + assert app.config['MAX_STATES'] >= 0 assert app.config['MAX_CHAT_SCROLLBACK'] >= 0 assert ( app.config['MAX_CHAT_MESSAGES'] >= app.config['MAX_CHAT_SCROLLBACK'] @@ -57,6 +65,9 @@ def create_app(): app.messages = app.messages_by_id.values() app.users = app.users_by_token.values() app.segments_directory_cache = DirectoryCache(config['stream']['segments_dir']) + app.captcha_factory = create_captcha_factory(app.config['CAPTCHA_FONTS']) + app.captcha_signer = create_captcha_signer(app.config['SECRET_KEY']) + app.captchas = OrderedDict() app.background_sleep = set() diff --git a/anonstream/captcha.py b/anonstream/captcha.py new file mode 100644 index 0000000..65960fc --- /dev/null +++ b/anonstream/captcha.py @@ -0,0 +1,49 @@ +import secrets + +from quart import current_app + +from anonstream.helpers.captcha import generate_captcha_digest, generate_captcha_image + +CONFIG = current_app.config +CAPTCHA_FACTORY = current_app.captcha_factory +CAPTCHA_SIGNER = current_app.captcha_signer +CAPTCHAS = current_app.captchas + +def generate_random_captcha_solution(): + return ''.join( + secrets.choice(CONFIG['CAPTCHA_ALPHABET']) + for _ in range(CONFIG['CAPTCHA_LENGTH']) + ) + +def _get_random_cached_captcha_digest(): + chosen_index = secrets.randbelow(len(CAPTCHAS)) + for index, digest in enumerate(CAPTCHAS): + if index == chosen_index: + break + return digest + +def get_random_captcha_digest(): + if len(CAPTCHAS) >= CONFIG['MAX_CAPTCHAS']: + digest = _get_random_cached_captcha_digest() + else: + salt = secrets.token_bytes(16) + solution = generate_random_captcha_solution() + digest = generate_captcha_digest(CAPTCHA_SIGNER, salt, solution) + CAPTCHAS[digest] = {'solution': solution} + while len(CAPTCHAS) >= CONFIG['MAX_CAPTCHAS']: + CAPTCHAS.popitem(last=False) + + return digest + +def get_captcha_image(digest): + try: + captcha = CAPTCHAS[digest] + except KeyError: + return None + else: + if 'image' not in captcha: + captcha['image'] = generate_captcha_image( + factory=CAPTCHA_FACTORY, + solution=captcha.pop('solution'), + ) + return captcha['image'] diff --git a/anonstream/chat.py b/anonstream/chat.py index 39bc458..f39cdf2 100644 --- a/anonstream/chat.py +++ b/anonstream/chat.py @@ -12,7 +12,7 @@ MESSAGES = current_app.messages USERS_BY_TOKEN = current_app.users_by_token USERS = current_app.users -class Rejected(Exception): +class Rejected(ValueError): pass def broadcast(users, payload): @@ -29,7 +29,11 @@ def messages_for_websocket(): get_scrollback(MESSAGES), )) -def add_chat_message(user, nonce, comment): +def add_chat_message(user, nonce, comment, ignore_empty=False): + # special case: if the comment is empty, do nothing and return + if ignore_empty and len(comment) == 0: + return + # check message message_id = generate_nonce_hash(nonce) if message_id in MESSAGES_BY_ID: diff --git a/anonstream/helpers/captcha.py b/anonstream/helpers/captcha.py new file mode 100644 index 0000000..4f38f53 --- /dev/null +++ b/anonstream/helpers/captcha.py @@ -0,0 +1,70 @@ +import base64 +import binascii +import hashlib +import io +from enum import Enum + +from itsdangerous import TimestampSigner +from itsdangerous.exc import BadTimeSignature, SignatureExpired +from quart import current_app + +CONFIG = current_app.config + +Answer = Enum('Answer', names=('OK', 'EXPIRED', 'BAD', 'MISSING')) + +def generate_captcha_image(factory, solution): + im = factory.create_captcha_image( + solution, + CONFIG['CAPTCHA_FOREGROUND_COLOUR'], + CONFIG['CAPTCHA_BACKGROUND_COLOUR'], + ) + buffer = io.BytesIO() + im.save(buffer, format='jpeg', quality=75, optimize=True) + buffer.seek(0) + return buffer.read() + +def _generate_captcha_unsigned_digest(salt, solution): + parts = ( + CONFIG['SECRET_KEY'] + + b'captcha-digest\0' + + salt + + solution.encode() + ) + raw_unsigned_digest = hashlib.sha256(parts).digest()[:16] + salt + return base64.b64encode(raw_unsigned_digest).removesuffix(b'=') + +def generate_captcha_digest(signer, salt, solution): + unsigned_digest = _generate_captcha_unsigned_digest(salt, solution) + return signer.sign(unsigned_digest).decode() + +def check_captcha_digest(signer, digest, answer): + if len(answer) == 0: + result = Answer.MISSING + else: + try: + unsigned_digest = signer.unsign( + digest, + max_age=CONFIG['CAPTCHA_LIFETIME'] + ) + except BadTimeSignature: + result = Answer.BAD + except SignatureExpired: + result = Answer.EXPIRED + else: + try: + raw_unsigned_digest = ( + base64.urlsafe_b64decode(unsigned_digest + b'=') + ) + except binascii.Error: + result = Answer.BAD + else: + salt = raw_unsigned_digest[16:] + true_unsigned_digest = ( + _generate_captcha_unsigned_digest(salt, answer) + ) + if unsigned_digest == true_unsigned_digest: + result = Answer.OK + else: + result = Answer.BAD + + return result diff --git a/anonstream/helpers/user.py b/anonstream/helpers/user.py index fd89e06..2448bd5 100644 --- a/anonstream/helpers/user.py +++ b/anonstream/helpers/user.py @@ -34,12 +34,13 @@ def generate_user(timestamp, token, broadcaster): return { 'token': token, 'token_hash': generate_token_hash(token), - 'websockets': set(), 'broadcaster': broadcaster, + 'verified': broadcaster, + 'websockets': set(), 'name': None, 'color': colour_to_color(colour), 'tripcode': None, - 'notices': OrderedDict(), + 'state': OrderedDict(), 'last': { 'seen': timestamp, 'watching': -inf, diff --git a/anonstream/routes/core.py b/anonstream/routes/core.py index 419580c..d22df91 100644 --- a/anonstream/routes/core.py +++ b/anonstream/routes/core.py @@ -1,5 +1,6 @@ -from quart import current_app, request, render_template, redirect, url_for +from quart import current_app, request, render_template, redirect, url_for, abort +from anonstream.captcha import get_captcha_image from anonstream.segments import CatSegments, Offline from anonstream.routes.wrappers import with_user_from, auth_required @@ -27,3 +28,13 @@ async def stream(user): @auth_required async def login(): return redirect(url_for('home')) + +@current_app.route('/captcha.jpg') +@with_user_from(request) +async def captcha(user): + digest = request.args.get('digest', '') + image = get_captcha_image(digest) + if image is None: + return abort(410) + else: + return image diff --git a/anonstream/routes/nojs.py b/anonstream/routes/nojs.py index 2d4affe..6d21617 100644 --- a/anonstream/routes/nojs.py +++ b/anonstream/routes/nojs.py @@ -1,14 +1,18 @@ from quart import current_app, request, render_template, redirect, url_for, escape, Markup -from anonstream.stream import get_stream_title -from anonstream.user import add_notice, pop_notice, try_change_appearance +from anonstream.captcha import get_random_captcha_digest from anonstream.chat import add_chat_message, Rejected +from anonstream.stream import get_stream_title +from anonstream.user import add_state, pop_state, try_change_appearance, verify, BadCaptcha from anonstream.routes.wrappers import with_user_from, render_template_with_etag -from anonstream.helpers.user import get_default_name from anonstream.helpers.chat import get_scrollback +from anonstream.helpers.user import get_default_name from anonstream.utils.chat import generate_nonce from anonstream.utils.user import concatenate_for_notice +CONFIG = current_app.config +USERS_BY_TOKEN = current_app.users_by_token + @current_app.route('/info.html') @with_user_from(request) async def nojs_info(user): @@ -24,13 +28,13 @@ async def nojs_chat(user): return await render_template_with_etag( 'nojs_chat.html', user=user, - users_by_token=current_app.users_by_token, + users_by_token=USERS_BY_TOKEN, messages=get_scrollback(current_app.messages), - timeout=current_app.config['THRESHOLD_NOJS_CHAT_TIMEOUT'], + timeout=CONFIG['THRESHOLD_NOJS_CHAT_TIMEOUT'], get_default_name=get_default_name, ) -@current_app.route('/chat/redirect') +@current_app.route('/chat/messages') @with_user_from(request) async def nojs_chat_redirect(user): return redirect(url_for('nojs_chat', _anchor='end')) @@ -38,35 +42,70 @@ async def nojs_chat_redirect(user): @current_app.route('/chat/form.html') @with_user_from(request) async def nojs_form(user): - notice_id = request.args.get('notice', type=int) - notice, verbose = pop_notice(user, notice_id) + state_id = request.args.get('state', type=int) + state = pop_state(user, state_id) prefer_chat_form = request.args.get('landing') != 'appearance' + digest = None if user['verified'] else get_random_captcha_digest() return await render_template( 'nojs_form.html', user=user, - notice=notice, - verbose=verbose, + state=state, prefer_chat_form=prefer_chat_form, nonce=generate_nonce(), + digest=digest, default_name=get_default_name(user), ) +@current_app.post('/chat/form') +@with_user_from(request) +async def nojs_form_redirect(user): + comment = (await request.form).get('comment', '') + if len(comment) > CONFIG['CHAT_COMMENT_MAX_LENGTH']: + comment = '' + + if comment: + state_id = add_state(user, comment=comment) + else: + state_id = None + + return redirect(url_for('nojs_form', state=state_id)) + @current_app.post('/chat/message') @with_user_from(request) async def nojs_submit_message(user): form = await request.form + comment = form.get('comment', '') - nonce = form.get('nonce', '') - + digest = form.get('captcha-digest', '') + answer = form.get('captcha-answer', '') try: - add_chat_message(user, nonce, comment) - except Rejected as e: + verification_happened = verify(user, digest, answer) + except BadCaptcha as e: notice, *_ = e.args - notice_id = add_notice(user, notice) + state_id = add_state(user, notice=notice, comment=comment) else: - notice_id = None + nonce = form.get('nonce', '') + try: + # if the comment is empty but the captcha was just solved, + # be lenient: don't raise an exception and don't create a notice + add_chat_message( + user, + nonce, + comment, + ignore_empty=verification_happened, + ) + except Rejected as e: + notice, *_ = e.args + state_id = add_state(user, notice=notice) + else: + state_id = None - return redirect(url_for('nojs_form', token=user['token'], landing='chat', notice=notice_id)) + return redirect(url_for( + 'nojs_form', + token=user['token'], + landing='chat', + state=state_id, + )) @current_app.post('/chat/appearance') @with_user_from(request) @@ -93,10 +132,10 @@ async def nojs_submit_appearance(user): else: notice = 'Changed appearance' - notice_id = add_notice(user, notice, verbose=len(errors) > 1) + state_id = add_state(user, notice=notice, verbose=len(errors) > 1) return redirect(url_for( 'nojs_form', token=user['token'], landing='appearance' if errors else 'chat', - notice=notice_id, + state=state_id, )) diff --git a/anonstream/routes/websocket.py b/anonstream/routes/websocket.py index 40f56ec..8c86edc 100644 --- a/anonstream/routes/websocket.py +++ b/anonstream/routes/websocket.py @@ -11,7 +11,7 @@ async def live(user): queue = asyncio.Queue(maxsize=0) user['websockets'].add(queue) - producer = websocket_outbound(queue) + producer = websocket_outbound(queue, user) consumer = websocket_inbound(queue, user) try: await asyncio.gather(producer, consumer) diff --git a/anonstream/templates/nojs_form.html b/anonstream/templates/nojs_form.html index 4922217..e46717e 100644 --- a/anonstream/templates/nojs_form.html +++ b/anonstream/templates/nojs_form.html @@ -81,11 +81,11 @@ #chat-form { display: grid; - grid: auto 2rem / auto 5rem; + grid: auto 2rem / auto min-content min-content 5rem; } #chat-form__comment { resize: none; - grid-column: 1 / span 2; + grid-column: 1 / span 4; background-color: #434347; border-radius: 4px; border: 2px solid transparent; @@ -100,6 +100,18 @@ background-color: black; border-color: #3584e4; } + #chat-form__captcha-image { + align-self: center; + font-size: 8pt; + color: inherit; + } + #chat-form__captcha-answer { + min-width: auto; + width: 8ch; + } + #chat-form__submit { + grid-column: 4; + } #appearance-form { grid-auto-rows: 1fr 1fr 2rem; @@ -166,7 +178,7 @@ #chat:target ~ #appearance-form { display: none; } - {% if notice != none %} + {% if state.notice %} #chat-form { display: none; } @@ -183,17 +195,22 @@
- {% if notice != none %} - -

{{ notice }}

+ {% if state.notice %} +
+

{{ state.notice }}

Click to dismiss
{% endif %}
- + - + {% if digest %} + + + + {% endif %} +
diff --git a/anonstream/user.py b/anonstream/user.py index 175fa6c..395bcb1 100644 --- a/anonstream/user.py +++ b/anonstream/user.py @@ -6,6 +6,7 @@ from quart import current_app from anonstream.chat import broadcast from anonstream.wrappers import try_except_log, with_timestamp from anonstream.helpers.user import is_visible +from anonstream.helpers.captcha import check_captcha_digest, Answer from anonstream.helpers.tripcode import generate_tripcode from anonstream.utils.colour import color_to_colour, get_contrast, NotAColor from anonstream.utils.user import user_for_websocket @@ -13,23 +14,27 @@ from anonstream.utils.user import user_for_websocket CONFIG = current_app.config MESSAGES = current_app.messages USERS = current_app.users +CAPTCHA_SIGNER = current_app.captcha_signer -class BadAppearance(Exception): +class BadAppearance(ValueError): pass -def add_notice(user, notice, verbose=False): - notice_id = time.time_ns() // 1_000_000 - user['notices'][notice_id] = (notice, verbose) - while len(user['notices']) > CONFIG['MAX_NOTICES']: - user['notices'].popitem(last=False) - return notice_id +class BadCaptcha(ValueError): + pass -def pop_notice(user, notice_id): +def add_state(user, **state): + state_id = time.time_ns() // 1_000_000 + user['state'][state_id] = state + while len(user['state']) > CONFIG['MAX_STATES']: + user['state'].popitem(last=False) + return state_id + +def pop_state(user, state_id): try: - notice, verbose = user['notices'].pop(notice_id) + state = user['state'].pop(state_id) except KeyError: - notice, verbose = None, False - return notice, verbose + state = None + return state def try_change_appearance(user, name, color, password, want_delete_tripcode, want_change_tripcode): @@ -117,3 +122,20 @@ def users_for_websocket(timestamp): user['token_hash']: user_for_websocket(user) for user in visible_users } + +def verify(user, digest, answer): + if user['verified']: + verification_happened = False + else: + match check_captcha_digest(CAPTCHA_SIGNER, digest, answer): + case Answer.MISSING: + raise BadCaptcha('Captcha is required') + case Answer.BAD: + raise BadCaptcha('Captcha was incorrect') + case Answer.EXPIRED: + raise BadCaptcha('Captcha has expired') + case Answer.OK: + user['verified'] = True + verification_happened = True + + return verification_happened diff --git a/anonstream/utils/captcha.py b/anonstream/utils/captcha.py new file mode 100644 index 0000000..2170e55 --- /dev/null +++ b/anonstream/utils/captcha.py @@ -0,0 +1,19 @@ +import hashlib + +from captcha.image import ImageCaptcha +from itsdangerous import TimestampSigner + +def create_captcha_factory(fonts): + return ImageCaptcha( + width=72, + height=30, + fonts=fonts, + font_sizes=(24, 27, 30), + ) + +def create_captcha_signer(secret_key): + return TimestampSigner( + secret_key=secret_key, + salt=b'captcha-signature', + digest_method=hashlib.sha256, + ) diff --git a/anonstream/utils/websocket.py b/anonstream/utils/websocket.py index d963d75..26295a0 100644 --- a/anonstream/utils/websocket.py +++ b/anonstream/utils/websocket.py @@ -13,4 +13,7 @@ def parse_websocket_data(receipt): if not isinstance(nonce, str): raise Malformed('malformed nonce') - return nonce, comment + digest = receipt.get('digest', '') + answer = receipt.get('answer', '') + + return nonce, comment, digest, answer diff --git a/anonstream/websocket.py b/anonstream/websocket.py index 4494f3e..5f5ea84 100644 --- a/anonstream/websocket.py +++ b/anonstream/websocket.py @@ -3,15 +3,16 @@ import asyncio from quart import current_app, websocket from anonstream.stream import get_stream_title, get_stream_uptime +from anonstream.captcha import get_random_captcha_digest from anonstream.chat import messages_for_websocket, add_chat_message, Rejected -from anonstream.user import users_for_websocket, see +from anonstream.user import users_for_websocket, see, verify, BadCaptcha from anonstream.wrappers import with_first_argument from anonstream.utils.chat import generate_nonce from anonstream.utils.websocket import parse_websocket_data, Malformed CONFIG = current_app.config -async def websocket_outbound(queue): +async def websocket_outbound(queue, user): payload = { 'type': 'init', 'nonce': generate_nonce(), @@ -24,6 +25,7 @@ async def websocket_outbound(queue): False: CONFIG['DEFAULT_ANON_NAME'], }, 'scrollback': CONFIG['MAX_CHAT_SCROLLBACK'], + 'digest': None if user['verified'] else get_random_captcha_digest(), } await websocket.send_json(payload) while True: @@ -35,7 +37,7 @@ async def websocket_inbound(queue, user): receipt = await websocket.receive_json() see(user) try: - nonce, comment = parse_websocket_data(receipt) + nonce, comment, digest, answer = parse_websocket_data(receipt) except Malformed as e: error , *_ = e.args payload = { @@ -44,17 +46,27 @@ async def websocket_inbound(queue, user): } else: try: - markup = add_chat_message(user, nonce, comment) - except Rejected as e: + verify(user, digest, answer) + except BadCaptcha as e: notice, *_ = e.args payload = { - 'type': 'reject', + 'type': 'captcha', 'notice': notice, + 'digest': get_random_captcha_digest(), } else: - payload = { - 'type': 'ack', - 'nonce': nonce, - 'next': generate_nonce(), - } + try: + markup = add_chat_message(user, nonce, comment) + except Rejected as e: + notice, *_ = e.args + payload = { + 'type': 'reject', + 'notice': notice, + } + else: + payload = { + 'type': 'ack', + 'nonce': nonce, + 'next': generate_nonce(), + } queue.put_nowait(payload) diff --git a/config.toml b/config.toml index 90541a8..df2e829 100644 --- a/config.toml +++ b/config.toml @@ -6,6 +6,24 @@ username = "broadcaster" [stream] segments_dir = "stream/" +[captcha] +lifetime = 1800 +fonts = [] +alphabet = "346abegkmprtuwxy" +length = 3 +background_color = "#232327" +foreground_color = "#dddddd" + +[memory] +states = 32 +captchas = 256 +chat_messages = 8192 +chat_scrollback = 256 + +[intervals] +sunset_users = 60 +expire_captchas = 60 + [names] broadcaster = "Broadcaster" anonymous = "Anonymous" @@ -16,15 +34,6 @@ max_name_length = 24 min_name_contrast = 3.0 background_color = "#232327" -[memory] -notices = 32 -chat_messages = 8192 -chat_scrollback = 256 - -[intervals] -sunset_users = 60 -expire_captchas = 60 - [thresholds] user_notwatching = 8 user_tentative = 20