Captchas, require captcha initially, generalize notices to states

このコミットが含まれているのは:
n9k 2022-02-20 04:23:32 +00:00
コミット b7313eec22
14個のファイルの変更336行の追加69行の削除

ファイルの表示

@ -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()

49
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']

ファイルの表示

@ -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:

70
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

ファイルの表示

@ -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,

ファイルの表示

@ -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

ファイルの表示

@ -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,
))

ファイルの表示

@ -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)

ファイルの表示

@ -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 @@
<body>
<div id="chat"></div>
<div id="appearance"></div>
{% if notice != none %}
<a id="notice" {% if verbose %}class="verbose" {% endif %}{% if prefer_chat_form %}href="#chat"{% else %}href="#appearance"{% endif %}>
<header><h1>{{ notice }}</h1></header>
{% if state.notice %}
<a id="notice" {% if state.verbose %}class="verbose" {% endif %}{% if prefer_chat_form %}href="#chat"{% else %}href="#appearance"{% endif %}>
<header><h1>{{ state.notice }}</h1></header>
<small>Click to dismiss</small>
</a>
{% endif %}
<form id="chat-form" action="{{ url_for('nojs_submit_message', token=user.token) }}" method="post">
<input type="hidden" name="nonce" value="{{ nonce }}">
<textarea id="chat-form__comment" name="comment" maxlength="512" required placeholder="Send a message..." rows="1" tabindex="1"></textarea>
<textarea id="chat-form__comment" name="comment" maxlength="512" {% if digest is none %}required {% endif %} placeholder="Send a message..." rows="1" tabindex="1">{{ state.comment }}</textarea>
<div id="chat-form__exit"><a href="#appearance">Settings</a></div>
<input id="chat-form__submit" type="submit" value="Chat" tabindex="2" accesskey="p">
{% if digest %}
<input type="hidden" name="captcha-digest" value="{{ digest }}">
<input id="chat-form__captcha-image" type="image" formaction="{{ url_for('nojs_form_redirect', token=user.token) }}" formnovalidate src="{{ url_for('captcha', token=user.token, digest=digest) }}" width="72" height="30" alt="Captcha failed to load" title="Click for a new captcha" tabindex="2">
<input id="chat-form__captcha-answer" name="captcha-answer" required placeholder="Captcha" tabindex="3">
{% endif %}
<input id="chat-form__submit" type="submit" value="Chat" tabindex="4" accesskey="p">
</form>
<form id="appearance-form" action="{{ url_for('nojs_submit_appearance', token=user.token) }}" method="post">
<label id="appearance-form__label-name" for="appearance-form__name">Name:</label>

ファイルの表示

@ -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

19
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,
)

ファイルの表示

@ -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

ファイルの表示

@ -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)

ファイルの表示

@ -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