Files
team-tryouts/tests/test_discord_identity.py

223 lines
7.0 KiB
Python

"""The Discord snowflake is a verified identity, not profile text.
SEC-AUTH-005. Discord OAuth used to put its result in two hidden inputs;
registration then trusted those client-controlled values, and edit_profile
let the account owner replace the snowflake later. The bot uses that value to
route private messages and authorize reaction-driven writes.
"""
import time
from urllib.parse import parse_qs, urlparse
from app.routes import auth as auth_module
from app.routes.auth import (
MIN_REGISTRATION_SECONDS,
REGISTRATION_ISSUED_KEY,
)
FORM = {
'username': 'brandnew',
'email': '[email protected]',
'password': 'Password123',
'confirm_password': 'Password123',
'full_name': 'Brand New',
}
def _allow_registration(client):
with client.session_transaction() as session:
session[REGISTRATION_ISSUED_KEY] = time.time() - MIN_REGISTRATION_SECONDS - 1
def _user(app, username='brandnew'):
from app.models import User
with app.app_context():
return User.query.filter_by(username=username).first()
class _DiscordResponse:
def __init__(self, payload):
self.payload = payload
def raise_for_status(self):
return None
def json(self):
return self.payload
def _complete_profile_oauth(client, monkeypatch, discord_user_id):
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_ID', 'client-id')
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_SECRET', 'client-secret')
monkeypatch.setattr(
auth_module,
'DISCORD_REDIRECT_URI',
'https://example.test/auth/discord/callback',
)
start = client.get('/auth/discord/login')
state = parse_qs(urlparse(start.headers['Location']).query)['state'][0]
monkeypatch.setattr(
auth_module.requests,
'post',
lambda *args, **kwargs: _DiscordResponse({'access_token': 'token'}),
)
monkeypatch.setattr(
auth_module.requests,
'get',
lambda *args, **kwargs: _DiscordResponse(
{'id': discord_user_id, 'username': 'verified-user'}
),
)
return client.get(f'/auth/discord/callback?code=code&state={state}')
class TestRegistrationIdentity:
def test_a_posted_snowflake_without_oauth_is_ignored(self, app, client):
_allow_registration(client)
client.post(
'/auth/register',
data=dict(FORM, discord_user_id='111111111111111111'),
)
assert _user(app).discord_user_id is None
def test_the_server_side_oauth_identity_wins_over_the_form(self, app, client):
verified = '222222222222222222'
_allow_registration(client)
with client.session_transaction() as session:
session['discord_oauth'] = {
'id': verified,
'username': 'verified-user',
}
client.post(
'/auth/register',
data=dict(
FORM,
discord_user_id='111111111111111111',
discord_username='forged-user',
),
)
created = _user(app)
assert created.discord_user_id == verified
assert created.discord_username == 'verified-user'
def test_an_oauth_identity_already_in_use_is_refused(self, app, client, make_user):
verified = '222222222222222222'
make_user('player', discord_user_id=verified)
_allow_registration(client)
with client.session_transaction() as session:
session['discord_oauth'] = {
'id': verified,
'username': 'verified-user',
}
client.post('/auth/register', data=FORM)
assert _user(app) is None
def test_the_registration_page_has_no_client_identity_field(self, client):
with client.session_transaction() as session:
session['discord_oauth'] = {
'id': '222222222222222222',
'username': 'verified-user',
}
page = client.get('/auth/register').get_data(as_text=True)
assert 'name="discord_user_id"' not in page
class TestProfileIdentity:
def test_a_profile_post_cannot_replace_the_verified_snowflake(self, app, client, as_role):
original = '222222222222222222'
user_id = as_role('player', discord_user_id=original)
client.post(
'/users/profile/edit',
data={
'username': 'player1',
'full_name': 'Player 1',
'email': '[email protected]',
'discord_user_id': '111111111111111111',
},
)
from app.models import User
with app.app_context():
assert (
app.extensions['sqlalchemy'].session.get(User, user_id).discord_user_id == original
)
def test_the_profile_form_does_not_offer_the_snowflake(self, client, as_role):
as_role('player', discord_user_id='222222222222222222')
page = client.get('/users/profile/edit').get_data(as_text=True)
assert 'name="discord_user_id"' not in page
assert '/auth/discord/login' in page
def test_a_signed_in_user_can_relink_only_through_oauth(
self, app, client, as_role, monkeypatch
):
user_id = as_role('player', discord_user_id='111111111111111111')
response = _complete_profile_oauth(
client,
monkeypatch,
discord_user_id='222222222222222222',
)
from app.models import User
assert '/users/profile/edit' in response.headers['Location']
with app.app_context():
user = app.extensions['sqlalchemy'].session.get(User, user_id)
assert user.discord_user_id == '222222222222222222'
assert user.discord_username == 'verified-user'
def test_profile_oauth_refuses_an_identity_owned_by_someone_else(
self, app, client, as_role, make_user, monkeypatch
):
taken = '222222222222222222'
make_user('player', discord_user_id=taken)
user_id = as_role('player', discord_user_id='111111111111111111')
_complete_profile_oauth(client, monkeypatch, discord_user_id=taken)
from app.models import User
with app.app_context():
assert (
app.extensions['sqlalchemy'].session.get(User, user_id).discord_user_id
== '111111111111111111'
)
class TestAdministrativeFallback:
def test_an_admin_cannot_assign_a_snowflake_twice(self, app, client, as_role, make_user):
taken = '222222222222222222'
make_user('player', discord_user_id=taken)
target_id = make_user('player')
as_role('admin')
client.post(
f'/users/{target_id}/edit',
data={
'full_name': 'Target Player',
'email': '[email protected]',
'role': 'player',
'discord_user_id': taken,
},
)
from app.models import User
with app.app_context():
assert app.extensions['sqlalchemy'].session.get(User, target_id).discord_user_id is None