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 %} - -