223 lines
7.0 KiB
Python
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
|