Files
baiyue-logistics-chat/tests/test_staff_sessions.py
T

253 lines
15 KiB
Python

"""Staff authorization uses real logins against disposable storage."""
import hashlib
import unittest
from unittest.mock import patch
import test_staff_login as credentials
from logistics_chat import db
class StaffSessionTests(unittest.TestCase):
add_staff = credentials.StaffLoginTests.add_staff
login = credentials.StaffLoginTests.login
def setUp(self):
credentials.StaffLoginTests.setUp(self)
self.app.config['RATE_LIMIT_PER_MIN'] = 1000
def signed_in(self):
self.add_staff('fixture-password')
response = self.login('fixture-password')
self.assertEqual(response.status_code, 200)
return self.client.get('/api/admin/session').json['csrf_token']
def test_anonymous_staff_pages_and_all_staff_endpoints_are_blocked(self):
staff_pages = {'/admin/dashboard', '/admin/orders'}
for path in staff_pages:
self.assertEqual(self.client.get(path).status_code, 302)
public = {'static', 'pages.index', 'pages.health', 'admin.admin', 'admin.admin_login',
'chat.stream_chat', 'chat.feedback', 'history.new_chat', 'history.get_history',
'history.get_messages', 'history.del_history', 'history.clear_all',
'files.upload_file', 'files.uploaded_file', 'business.add_ticket', 'business.rate_chat',
'visitor.visitor_session', 'visitor.visitor_human', 'customer.session',
'customer.login', 'customer.logout', 'customer.orders', 'customer.order',
'speech.speech_config', 'speech.speech_transcribe'}
for rule in self.app.url_map.iter_rules():
if rule.endpoint in public or rule.rule in staff_pages:
continue
method = 'POST' if 'POST' in rule.methods else 'GET'
path = rule.rule.replace('<path:name>', 'fixture.txt')
with self.subTest(path=path):
self.assertEqual(self.client.open(path, method=method, json={}).status_code, 401)
self.assertEqual(self.client.get('/api/future-private-route').status_code, 401)
self.assertEqual(self.client.get('/').status_code, 200)
self.assertEqual(self.client.get('/healthz').status_code, 200)
def test_customer_ticket_and_rating_keep_their_existing_guest_contract(self):
self.client.post('/api/visitor/session', json={'key':'a'*64})
self.assertEqual(self.client.post('/api/ticket', json={'content':'fixture ticket'}).status_code, 200)
self.assertEqual(self.client.post('/api/rate', json={'sid':999, 'stars':5}).status_code, 404)
def test_cookie_and_database_store_24_hour_opaque_sessions(self):
self.add_staff('fixture-password')
response = self.client.post('/admin/login', json={'username':'staff-fixture', 'password':'fixture-password'},
base_url='https://localhost')
cookie = response.headers['Set-Cookie']
for flag in ('HttpOnly', 'Secure', 'SameSite=Lax', 'Max-Age=86400'):
self.assertIn(flag, cookie)
token = self.client.get_cookie('baiyue_staff').value
with self.app.app_context():
row = db.connect().execute('SELECT token_hash FROM staff_sessions').fetchone()
self.assertEqual(row[0], hashlib.sha256(token.encode()).hexdigest())
self.assertNotIn(token, self.client.get('/admin/dashboard').text)
self.assertEqual(self.client.get('/admin/dashboard').status_code, 200)
def test_staff_writes_require_csrf_and_same_origin(self):
csrf = self.signed_in()
self.assertEqual(self.client.post('/api/human/note', json={}).status_code, 403)
self.assertEqual(self.client.post('/admin/logout', headers={'X-CSRF-Token':'bad'}).status_code, 403)
self.assertEqual(self.client.post('/admin/logout', headers={'X-CSRF-Token':csrf, 'Origin':'https://evil.test'}).status_code, 403)
self.assertEqual(self.client.get('/api/admin/session').status_code, 200)
def test_logout_revokes_replayed_cookie_and_preserves_data(self):
csrf = self.signed_in()
token = self.client.get_cookie('baiyue_staff').value
self.assertEqual(self.client.post('/admin/logout', headers={'X-CSRF-Token':csrf}).status_code, 200)
self.assertIsNone(self.client.get_cookie('baiyue_staff'))
self.client.set_cookie('baiyue_staff', token)
self.assertEqual(self.client.get('/api/admin/stats').status_code, 401)
with self.app.app_context():
self.assertEqual(db.connect().execute('SELECT COUNT(*) FROM agents').fetchone()[0], 1)
def test_login_rotates_current_token(self):
self.signed_in()
old = self.client.get_cookie('baiyue_staff').value
self.login('fixture-password')
self.assertNotEqual(old, self.client.get_cookie('baiyue_staff').value)
self.client.set_cookie('baiyue_staff', old)
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
def test_sessions_expire_at_24_hours_without_renewing_on_requests_or_restart(self):
from logistics_chat import create_app
with patch('logistics_chat.staff_service.time.time', return_value=1000):
self.signed_in()
token = self.client.get_cookie('baiyue_staff').value
restarted = self.client
for elapsed in (8*3600, 23*3600, 24*3600-1):
with self.subTest(elapsed=elapsed), patch('logistics_chat.staff_service.time.time', return_value=1000+elapsed):
response = self.client.get('/api/admin/session')
self.assertEqual(response.status_code, 200)
self.assertNotIn('Set-Cookie', response.headers)
self.assertEqual(self.client.get_cookie('baiyue_staff').value, token)
restarted = create_app(dict(self.app.config)).test_client()
restarted.set_cookie('baiyue_staff', token)
self.assertEqual(restarted.get('/api/admin/session').status_code, 200)
with patch('logistics_chat.staff_service.time.time', return_value=1000+24*3600):
for client in (self.client, restarted):
self.assertEqual(client.get('/api/admin/session').status_code, 401)
self.assertEqual(client.get('/admin/dashboard').status_code, 302)
def test_sessions_survive_app_restart_until_explicit_logout(self):
from logistics_chat import create_app
self.signed_in()
token = self.client.get_cookie('baiyue_staff').value
restarted = create_app(dict(self.app.config)).test_client()
restarted.set_cookie('baiyue_staff', token)
response = restarted.get('/api/admin/session')
self.assertEqual(response.status_code, 200)
self.assertEqual(restarted.post('/admin/logout', headers={'X-CSRF-Token':response.json['csrf_token']}).status_code, 200)
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
def test_another_browser_login_does_not_remove_existing_session(self):
csrf = self.signed_in()
another = self.app.test_client()
self.assertEqual(another.post('/admin/login', json={'username':'staff-fixture','password':'fixture-password'}).status_code, 200)
self.assertEqual(self.client.get('/api/admin/session').status_code, 200)
self.assertEqual(self.client.post('/admin/logout', headers={'X-CSRF-Token':csrf}).status_code, 200)
self.assertEqual(another.get('/api/admin/session').status_code, 200)
def test_upgrade_preserves_deadlines_and_revokes_unbounded_and_expired_sessions(self):
self.signed_in()
with self.app.app_context():
connection = db.connect()
connection.execute('UPDATE staff_sessions SET expires_at=2000')
connection.execute('INSERT INTO staff_sessions SELECT ?,username,auth_stamp,csrf_token,500 FROM staff_sessions',
(hashlib.sha256(('b'*64).encode()).hexdigest(),))
connection.execute('INSERT INTO staff_sessions SELECT ?,username,auth_stamp,csrf_token,0 FROM staff_sessions LIMIT 1',
(hashlib.sha256(('c'*64).encode()).hexdigest(),))
connection.commit()
with patch('logistics_chat.staff_service.time.time', return_value=1000):
db.init_db()
with patch('logistics_chat.staff_service.time.time', return_value=1500):
self.assertEqual(self.client.get('/api/admin/session').status_code, 200)
for token in ('b'*64, 'c'*64):
other = self.app.test_client()
other.set_cookie('baiyue_staff', token)
self.assertEqual(other.get('/api/admin/session').status_code, 401)
with patch('logistics_chat.staff_service.time.time', return_value=2000):
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
def test_password_change_and_account_deletion_invalidate_sessions(self):
self.signed_in()
with self.app.app_context():
connection = db.connect()
connection.execute("UPDATE agents SET password='new-fixture-password'")
connection.commit()
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
self.login('new-fixture-password')
with self.app.app_context():
connection = db.connect()
connection.execute('DELETE FROM agents')
connection.commit()
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
def test_legacy_agent_id_session_schema_is_upgraded_without_changing_credentials(self):
from werkzeug.security import generate_password_hash
password_hash = generate_password_hash('fixture-password', method='scrypt:1024:8:1')
with self.app.app_context():
connection = db.connect()
connection.execute('DROP TABLE staff_sessions')
connection.execute('DROP TABLE agents')
connection.execute('CREATE TABLE agents(id INTEGER PRIMARY KEY, username TEXT UNIQUE, '
"password TEXT, name TEXT, role TEXT NOT NULL DEFAULT 'agent')")
connection.execute('INSERT INTO agents VALUES(?,?,?,?,?)', (7,'staff-fixture',password_hash,'测试客服','admin'))
connection.execute('CREATE TABLE staff_sessions(id TEXT PRIMARY KEY, agent_id INTEGER NOT NULL, '
'created_at REAL NOT NULL, expires_at REAL NOT NULL, password_version TEXT NOT NULL)')
connection.execute('INSERT INTO staff_sessions VALUES(?,?,?,?,?)', ('legacy-cookie',7,1000,9999999999,'legacy-version'))
connection.execute("INSERT INTO tickets(content,phone) VALUES('fixture-ticket','fixture-phone')")
connection.commit()
db.init_db()
db.init_db()
self.assertEqual(connection.execute('SELECT id,username,password,name,role FROM agents').fetchone(),
(7,'staff-fixture',password_hash,'测试客服','admin'))
self.assertEqual(connection.execute('SELECT content FROM tickets').fetchone()[0], 'fixture-ticket')
self.assertEqual(self.login('fixture-password').status_code, 200)
self.assertEqual(self.client.get('/api/admin/session').status_code, 200)
self.assertEqual(self.client.get('/admin/dashboard').status_code, 200)
with self.app.app_context():
connection = db.connect()
connection.execute("UPDATE agents SET password='new-fixture-password'")
connection.commit()
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
self.login('new-fixture-password')
with self.app.app_context():
connection = db.connect()
connection.execute('DELETE FROM agents')
connection.commit()
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
def test_cross_site_login_and_form_login_are_rejected(self):
self.add_staff('fixture-password')
self.assertEqual(self.client.post('/admin/login', json={'username':'staff-fixture','password':'fixture-password'},
headers={'Origin':'https://evil.test'}).status_code, 403)
self.assertEqual(self.client.post('/admin/login', data={'username':'staff-fixture','password':'fixture-password'}).status_code, 415)
def test_restoring_password_or_recreating_account_cannot_restore_old_session(self):
for change in ('password', 'account', 'replace'):
with self.subTest(change=change):
if change == 'password':
self.signed_in()
else:
self.login('fixture-password')
with self.app.app_context():
connection = db.connect()
if change == 'password':
connection.execute("UPDATE agents SET password='temporary-password'")
connection.execute("UPDATE agents SET password='fixture-password'")
elif change == 'account':
connection.execute('DELETE FROM agents')
connection.execute('INSERT INTO agents VALUES(?,?,?)', ('staff-fixture','fixture-password','测试客服'))
else:
for password in ('temporary-password', 'fixture-password'):
connection.execute('INSERT OR REPLACE INTO agents VALUES(?,?,?)', ('staff-fixture',password,'测试客服'))
connection.commit()
self.assertEqual(self.client.get('/api/admin/session').status_code, 401)
def test_failed_logins_are_throttled_separately_from_polling(self):
self.add_staff('fixture-password')
for _ in range(5):
self.assertEqual(self.login('wrong').status_code, 401)
self.assertEqual(self.login('wrong').status_code, 429)
self.assertEqual(self.client.get('/healthz').status_code, 200)
def test_random_cookie_and_customer_session_do_not_authorize_staff(self):
self.client.set_cookie('baiyue_staff', 'a'*64)
self.client.set_cookie('baiyue_customer', 'b'*64)
self.client.post('/api/visitor/session', json={'key':'c'*64})
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
self.assertEqual(self.client.get('/api/tickets').status_code, 401)
def test_proxy_peer_failures_do_not_lock_other_accounts_or_trust_spoofed_headers(self):
self.add_staff('fixture-password')
with self.app.app_context():
connection = db.connect()
connection.execute('INSERT INTO agents VALUES(?,?,?)', ('other-fixture','other-password','Other Staff'))
connection.commit()
for _ in range(5):
self.assertEqual(self.login('wrong').status_code, 401)
fresh = self.app.test_client()
self.assertEqual(fresh.post('/admin/login', json={'username':'staff-fixture','password':'wrong'},
headers={'X-Forwarded-For':'203.0.113.42'}).status_code, 429)
self.assertEqual(fresh.post('/admin/login', json={'username':'other-fixture','password':'other-password'}).status_code, 200)