OTP security hardened
This commit is contained in:
@@ -239,6 +239,32 @@ def test_admin_can_add_list_and_remove_users():
|
||||
assert client.put('/api/admin/users/user-1', headers=headers, json={'is_admin': False}).status_code == 400
|
||||
|
||||
|
||||
def test_admin_can_reset_another_users_otp():
|
||||
admin_headers = login_headers()
|
||||
user_login = client.post('/api/auth/login', json={
|
||||
'email': 'bob@example.com',
|
||||
'password': 'secret123',
|
||||
}).json()
|
||||
user_headers = {'Authorization': f"Bearer {user_login['access_token']}"}
|
||||
setup = client.post('/api/user/otp/setup', headers=user_headers)
|
||||
assert setup.status_code == 200
|
||||
secret = setup.json()['secret']
|
||||
recovery_code = setup.json()['recovery_codes'][0]
|
||||
assert client.post('/api/user/otp', headers=user_headers, json={
|
||||
'action': 'enable', 'code': current_code(secret),
|
||||
}).status_code == 200
|
||||
|
||||
assert client.post('/api/admin/users/user-2/otp/reset', headers=admin_headers).json() == {
|
||||
'status': 'otp_reset', 'enabled': False, 'user_id': 'user-2',
|
||||
}
|
||||
assert client.get('/api/user/otp', headers=user_headers).json() == {'enabled': False}
|
||||
assert client.post('/api/user/otp/recover', headers=user_headers, json={
|
||||
'current_password': 'secret123', 'recovery_code': recovery_code,
|
||||
}).status_code == 400
|
||||
assert client.post('/api/admin/users/user-2/otp/reset', headers=login_headers('bob')).status_code == 403
|
||||
assert client.post('/api/admin/users/missing-user/otp/reset', headers=admin_headers).status_code == 404
|
||||
|
||||
|
||||
def test_new_user_must_verify_email_before_login():
|
||||
headers = login_headers()
|
||||
username = f'unverified-{uuid4().hex}'
|
||||
|
||||
@@ -10,7 +10,7 @@ def test_database_migrations_are_versioned_and_idempotent():
|
||||
connection = sqlite3.connect(':memory:')
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 15
|
||||
assert get_schema_version(connection) == 16
|
||||
tables = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
@@ -27,6 +27,6 @@ def test_database_migrations_are_versioned_and_idempotent():
|
||||
assert set(DEFAULT_TAGS) <= seeded_tags
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 15
|
||||
assert get_schema_version(connection) == 16
|
||||
|
||||
connection.close()
|
||||
@@ -89,6 +89,8 @@ def test_user_can_enable_and_use_otp():
|
||||
assert setup.status_code == 200
|
||||
secret = setup.json()['secret']
|
||||
assert setup.json()['otpauth_url'].startswith('otpauth://totp/')
|
||||
recovery_codes = setup.json()['recovery_codes']
|
||||
assert len(recovery_codes) == 10
|
||||
|
||||
enabled = client.post('/api/user/otp', headers=headers, json={
|
||||
'action': 'enable', 'code': current_code(secret),
|
||||
@@ -103,12 +105,36 @@ def test_user_can_enable_and_use_otp():
|
||||
assert otp_login.status_code == 200
|
||||
|
||||
disabled = client.post('/api/user/otp', headers=headers, json={
|
||||
'action': 'disable', 'code': current_code(secret),
|
||||
'action': 'disable', 'code': current_code(secret), 'current_password': 'secret123',
|
||||
})
|
||||
assert disabled.status_code == 200
|
||||
assert disabled.json()['enabled'] is False
|
||||
|
||||
|
||||
def test_otp_recovery_code_requires_password_and_is_single_use():
|
||||
login = client.post('/api/auth/login', json={'email': 'alice@example.com', 'password': 'secret123'}).json()
|
||||
headers = {'Authorization': f"Bearer {login['access_token']}"}
|
||||
setup = client.post('/api/user/otp/setup', headers=headers)
|
||||
secret = setup.json()['secret']
|
||||
recovery_code = setup.json()['recovery_codes'][0]
|
||||
assert client.post('/api/user/otp', headers=headers, json={
|
||||
'action': 'enable', 'code': current_code(secret),
|
||||
}).status_code == 200
|
||||
|
||||
rejected = client.post('/api/user/otp/recover', headers=headers, json={
|
||||
'current_password': 'wrong-password', 'recovery_code': recovery_code,
|
||||
})
|
||||
assert rejected.status_code == 400
|
||||
recovered = client.post('/api/user/otp/recover', headers=headers, json={
|
||||
'current_password': 'secret123', 'recovery_code': recovery_code,
|
||||
})
|
||||
assert recovered.status_code == 200
|
||||
reused = client.post('/api/user/otp/recover', headers=headers, json={
|
||||
'current_password': 'secret123', 'recovery_code': recovery_code,
|
||||
})
|
||||
assert reused.status_code == 400
|
||||
|
||||
|
||||
def test_verified_alternative_can_become_primary():
|
||||
login = client.post('/api/auth/login', json={'email': 'alice@example.com', 'password': 'secret123'}).json()
|
||||
headers = {'Authorization': f"Bearer {login['access_token']}"}
|
||||
|
||||
Reference in New Issue
Block a user