fix(auth): garder l identite Discord du cote verifie
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user