diff --git a/backend/main.py b/backend/main.py index d064b40..0006868 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,6 +18,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from backend import config, db, push, telegram from backend.middleware import ( + _verify_jwt_token, create_auth_token, rate_limit_auth_login, rate_limit_auth_register, @@ -176,13 +177,36 @@ async def signal( ) -> SignalResponse: if credentials is None: raise HTTPException(status_code=401, detail="Unauthorized") - key_hash = _hash_api_key(credentials.credentials) - stored_hash = await db.get_api_key_hash_by_uuid(body.user_id) - if stored_hash is None or not secrets.compare_digest(key_hash, stored_hash): - raise HTTPException(status_code=401, detail="Unauthorized") - if await db.is_user_blocked(body.user_id): - raise HTTPException(status_code=403, detail="User is blocked") + user_identifier: str = "" + user_name: str = "" + + # Try JWT auth first (new registration flow) + jwt_payload = None + try: + jwt_payload = _verify_jwt_token(credentials.credentials) + except Exception: + pass + + if jwt_payload is not None: + reg_id = int(jwt_payload["sub"]) + reg = await db.get_registration(reg_id) + if reg is None or reg["status"] != "approved": + raise HTTPException(status_code=401, detail="Unauthorized") + user_identifier = reg["login"] + user_name = reg["login"] + else: + # Legacy api_key auth + if not body.user_id: + raise HTTPException(status_code=401, detail="Unauthorized") + key_hash = _hash_api_key(credentials.credentials) + stored_hash = await db.get_api_key_hash_by_uuid(body.user_id) + if stored_hash is None or not secrets.compare_digest(key_hash, stored_hash): + raise HTTPException(status_code=401, detail="Unauthorized") + if await db.is_user_blocked(body.user_id): + raise HTTPException(status_code=403, detail="User is blocked") + user_identifier = body.user_id + user_name = await db.get_user_name(body.user_id) or body.user_id[:8] geo = body.geo lat = geo.lat if geo else None @@ -190,23 +214,21 @@ async def signal( accuracy = geo.accuracy if geo else None signal_id = await db.save_signal( - user_uuid=body.user_id, + user_uuid=user_identifier, timestamp=body.timestamp, lat=lat, lon=lon, accuracy=accuracy, ) - user_name = await db.get_user_name(body.user_id) ts = datetime.fromtimestamp(body.timestamp / 1000, tz=timezone.utc) - name = user_name or body.user_id[:8] geo_info = ( f"📍 {lat}, {lon} (±{accuracy}м)" if geo else "Без геолокации" ) text = ( - f"🚨 Сигнал от {name}\n" + f"🚨 Сигнал от {user_name}\n" f"⏰ {ts.strftime('%H:%M:%S')} UTC\n" f"{geo_info}" ) diff --git a/backend/models.py b/backend/models.py index b3d847a..5137e1a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -22,7 +22,7 @@ class GeoData(BaseModel): class SignalRequest(BaseModel): - user_id: str = Field(..., pattern=r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$') + user_id: Optional[str] = None # UUID for legacy api_key auth; omit for JWT auth timestamp: int = Field(..., gt=0) geo: Optional[GeoData] = None diff --git a/frontend/app.js b/frontend/app.js index de54b6f..309d784 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -39,31 +39,26 @@ function _initStorage() { // ========== User identity ========== -function _getOrCreateUserId() { - let id = _storage.getItem('baton_user_id'); - if (!id) { - id = crypto.randomUUID(); - _storage.setItem('baton_user_id', id); - } - return id; -} - function _isRegistered() { - return _storage.getItem('baton_registered') === '1'; + return !!_storage.getItem('baton_auth_token'); } function _getUserName() { - return _storage.getItem('baton_user_name') || ''; + return _storage.getItem('baton_login') || ''; } -function _getApiKey() { - return _storage.getItem('baton_api_key') || ''; +function _getAuthToken() { + return _storage.getItem('baton_auth_token') || ''; } -function _saveRegistration(name, apiKey) { - _storage.setItem('baton_user_name', name); - _storage.setItem('baton_registered', '1'); - if (apiKey) _storage.setItem('baton_api_key', apiKey); +function _saveAuth(token, login) { + _storage.setItem('baton_auth_token', token); + _storage.setItem('baton_login', login); +} + +function _clearAuth() { + _storage.removeItem('baton_auth_token'); + _storage.removeItem('baton_login'); } function _getInitials(name) { @@ -100,6 +95,14 @@ function _setRegStatus(msg, cls) { el.hidden = !msg; } +function _setLoginStatus(msg, cls) { + const el = document.getElementById('login-status'); + if (!el) return; + el.textContent = msg; + el.className = 'reg-status' + (cls ? ' reg-status--' + cls : ''); + el.hidden = !msg; +} + function _showView(id) { ['view-login', 'view-register'].forEach((vid) => { const el = document.getElementById(vid); @@ -157,23 +160,38 @@ function _getGeo() { // ========== Handlers ========== -async function _handleRegister() { - const input = document.getElementById('name-input'); - const btn = document.getElementById('btn-confirm'); - const name = input.value.trim(); - if (!name) return; +async function _handleLogin() { + const loginInput = document.getElementById('login-input'); + const passInput = document.getElementById('login-password'); + const btn = document.getElementById('btn-login'); + const login = loginInput.value.trim(); + const password = passInput.value; + if (!login || !password) return; btn.disabled = true; - _setStatus('', ''); + _setLoginStatus('', ''); try { - const uuid = _getOrCreateUserId(); - const data = await _apiPost('/api/register', { uuid, name }); - _saveRegistration(name, data.api_key); + const data = await _apiPost('/api/auth/login', { + login_or_email: login, + password: password, + }); + _saveAuth(data.token, data.login); + passInput.value = ''; _updateUserAvatar(); _showMain(); - } catch (_) { - _setStatus('Error. Please try again.', 'error'); + } catch (err) { + let msg = 'Ошибка входа. Попробуйте ещё раз.'; + if (err && err.message) { + const colonIdx = err.message.indexOf(': '); + if (colonIdx !== -1) { + try { + const parsed = JSON.parse(err.message.slice(colonIdx + 2)); + if (parsed.detail) msg = parsed.detail; + } catch (_) {} + } + } + _setLoginStatus(msg, 'error'); btn.disabled = false; } } @@ -186,9 +204,15 @@ function _setSosState(state) { } async function _handleSignal() { - // v1: no offline queue — show error and return (decision #1019) if (!navigator.onLine) { - _setStatus('No connection. Check your network and try again.', 'error'); + _setStatus('Нет соединения. Проверьте сеть и попробуйте снова.', 'error'); + return; + } + + const token = _getAuthToken(); + if (!token) { + _clearAuth(); + _showOnboarding(); return; } @@ -197,16 +221,13 @@ async function _handleSignal() { try { const geo = await _getGeo(); - const uuid = _getOrCreateUserId(); - const body = { user_id: uuid, timestamp: Date.now() }; + const body = { timestamp: Date.now() }; if (geo) body.geo = geo; - const apiKey = _getApiKey(); - const authHeaders = apiKey ? { Authorization: 'Bearer ' + apiKey } : {}; - await _apiPost('/api/signal', body, authHeaders); + await _apiPost('/api/signal', body, { Authorization: 'Bearer ' + token }); _setSosState('success'); - _setStatus('Signal sent!', 'success'); + _setStatus('Сигнал отправлен!', 'success'); setTimeout(() => { _setSosState('default'); _setStatus('', ''); @@ -214,9 +235,11 @@ async function _handleSignal() { } catch (err) { _setSosState('default'); if (err && err.status === 401) { - _setStatus('Session expired or key is invalid. Please re-register.', 'error'); + _clearAuth(); + _setStatus('Сессия истекла. Войдите заново.', 'error'); + setTimeout(() => _showOnboarding(), 1500); } else { - _setStatus('Error sending. Try again.', 'error'); + _setStatus('Ошибка отправки. Попробуйте ещё раз.', 'error'); } } } @@ -227,28 +250,36 @@ function _showOnboarding() { _showScreen('screen-onboarding'); _showView('view-login'); - const input = document.getElementById('name-input'); - const btn = document.getElementById('btn-confirm'); + const loginInput = document.getElementById('login-input'); + const passInput = document.getElementById('login-password'); + const btnLogin = document.getElementById('btn-login'); - input.addEventListener('input', () => { - btn.disabled = input.value.trim().length === 0; + function _updateLoginBtn() { + btnLogin.disabled = !loginInput.value.trim() || !passInput.value; + } + + loginInput.addEventListener('input', _updateLoginBtn); + passInput.addEventListener('input', _updateLoginBtn); + passInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !btnLogin.disabled) _handleLogin(); }); - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !btn.disabled) _handleRegister(); - }); - btn.addEventListener('click', _handleRegister); + btnLogin.addEventListener('click', _handleLogin); const btnToRegister = document.getElementById('btn-switch-to-register'); if (btnToRegister) { btnToRegister.addEventListener('click', () => { _setRegStatus('', ''); + _setLoginStatus('', ''); _showView('view-register'); }); } const btnToLogin = document.getElementById('btn-switch-to-login'); if (btnToLogin) { - btnToLogin.addEventListener('click', () => _showView('view-login')); + btnToLogin.addEventListener('click', () => { + _setLoginStatus('', ''); + _showView('view-login'); + }); } const btnRegister = document.getElementById('btn-register'); @@ -403,11 +434,7 @@ async function _handleSignUp() { function _init() { _initStorage(); - // Pre-generate and persist UUID on first visit (per arch spec flow) - _getOrCreateUserId(); - - // Private mode graceful degradation (decision #1041): - // show inline banner with explicit action guidance when localStorage is unavailable + // Private mode graceful degradation (decision #1041) if (_storageType !== 'local') { const banner = document.getElementById('private-mode-banner'); if (banner) banner.hidden = false; @@ -418,7 +445,7 @@ function _init() { window.addEventListener('online', _updateNetworkIndicator); window.addEventListener('offline', _updateNetworkIndicator); - // Route to correct screen + // Route to correct screen based on JWT token presence if (_isRegistered()) { _showMain(); } else { diff --git a/frontend/index.html b/frontend/index.html index f04d171..7707517 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -37,23 +37,32 @@
- +
- +
diff --git a/tests/test_arch_009.py b/tests/test_arch_009.py index 9457374..01ba1c4 100644 --- a/tests/test_arch_009.py +++ b/tests/test_arch_009.py @@ -222,9 +222,9 @@ def test_html_loads_app_js() -> None: assert "/app.js" in _html() -def test_html_has_name_input() -> None: - """index.html must have name input field for onboarding.""" - assert 'id="name-input"' in _html() +def test_html_has_login_input() -> None: + """index.html must have login input field for onboarding.""" + assert 'id="login-input"' in _html() # --------------------------------------------------------------------------- @@ -316,31 +316,19 @@ def _app_js() -> str: return (FRONTEND / "app.js").read_text(encoding="utf-8") -def test_app_uses_crypto_random_uuid() -> None: - """app.js must generate UUID via crypto.randomUUID().""" - assert "crypto.randomUUID()" in _app_js() +def test_app_posts_to_auth_login() -> None: + """app.js must send POST to /api/auth/login during login.""" + assert "/api/auth/login" in _app_js() -def test_app_posts_to_api_register() -> None: - """app.js must send POST to /api/register during onboarding.""" - assert "/api/register" in _app_js() +def test_app_posts_to_auth_register() -> None: + """app.js must send POST to /api/auth/register during registration.""" + assert "/api/auth/register" in _app_js() -def test_app_register_sends_uuid() -> None: - """app.js must include uuid in the /api/register request body.""" - app = _app_js() - # The register call must include uuid in the payload - register_section = re.search( - r"_apiPost\(['\"]\/api\/register['\"].*?\)", app, re.DOTALL - ) - assert register_section, "No _apiPost('/api/register') call found" - assert "uuid" in register_section.group(0), \ - "uuid not included in /api/register call" - - -def test_app_uuid_saved_to_storage() -> None: - """app.js must persist UUID to storage (baton_user_id key).""" - assert "baton_user_id" in _app_js() +def test_app_stores_auth_token() -> None: + """app.js must persist JWT token to storage.""" + assert "baton_auth_token" in _app_js() assert "setItem" in _app_js() @@ -434,16 +422,14 @@ def test_app_posts_to_api_signal() -> None: assert "/api/signal" in _app_js() -def test_app_signal_sends_user_id() -> None: - """app.js must include user_id (UUID) in the /api/signal request body.""" +def test_app_signal_sends_auth_header() -> None: + """app.js must include Authorization Bearer header in /api/signal request.""" app = _app_js() - # The signal body may be built in a variable before passing to _apiPost - # Look for user_id key in the context around /api/signal signal_area = re.search( - r"user_id.*?_apiPost\(['\"]\/api\/signal", app, re.DOTALL + r"_apiPost\(['\"]\/api\/signal['\"].*Authorization.*Bearer", app, re.DOTALL ) assert signal_area, \ - "user_id must be set in the request body before calling _apiPost('/api/signal')" + "Authorization Bearer header must be set in _apiPost('/api/signal') call" def test_app_sos_button_click_calls_handle_signal() -> None: @@ -456,15 +442,15 @@ def test_app_sos_button_click_calls_handle_signal() -> None: "btn-sos must be connected to _handleSignal" -def test_app_signal_uses_uuid_from_storage() -> None: - """app.js must retrieve UUID from storage (_getOrCreateUserId) before sending signal.""" +def test_app_signal_uses_token_from_storage() -> None: + """app.js must retrieve auth token from storage before sending signal.""" app = _app_js() handle_signal = re.search( r"async function _handleSignal\(\).*?^}", app, re.MULTILINE | re.DOTALL ) assert handle_signal, "_handleSignal function not found" - assert "_getOrCreateUserId" in handle_signal.group(0), \ - "_handleSignal must call _getOrCreateUserId() to get UUID" + assert "_getAuthToken" in handle_signal.group(0), \ + "_handleSignal must call _getAuthToken() to get JWT token" # --------------------------------------------------------------------------- diff --git a/tests/test_fix_012.py b/tests/test_fix_012.py index 64356de..324091a 100644 --- a/tests/test_fix_012.py +++ b/tests/test_fix_012.py @@ -102,10 +102,10 @@ def test_register_request_rejects_old_placeholder_uuid(bad_uuid: str) -> None: "agg-uuid-001", "create-uuid-001", ]) -def test_signal_request_rejects_old_placeholder_uuid(bad_uuid: str) -> None: - """SignalRequest.user_id must reject old-style placeholder strings.""" - with pytest.raises(ValidationError): - SignalRequest(user_id=bad_uuid, timestamp=1700000000000) +def test_signal_request_accepts_any_user_id_string(bad_uuid: str) -> None: + """SignalRequest.user_id is optional (no pattern) — validation is at endpoint level.""" + req = SignalRequest(user_id=bad_uuid, timestamp=1700000000000) + assert req.user_id == bad_uuid # --------------------------------------------------------------------------- @@ -152,17 +152,16 @@ def test_register_request_rejects_uuid_v3_version_digit() -> None: RegisterRequest(uuid="550e8400-e29b-31d4-a716-446655440000", name="Test") -def test_signal_request_rejects_uuid_wrong_variant_bits() -> None: - """UUID with invalid variant bits (0xxx in fourth group) must be rejected.""" - with pytest.raises(ValidationError): - # fourth group starts with '0' — not 8/9/a/b variant - SignalRequest(user_id="550e8400-e29b-41d4-0716-446655440000", timestamp=1700000000000) +def test_signal_request_accepts_any_variant_bits() -> None: + """SignalRequest.user_id is now optional and unvalidated (JWT auth doesn't use it).""" + req = SignalRequest(user_id="550e8400-e29b-41d4-0716-446655440000", timestamp=1700000000000) + assert req.user_id is not None -def test_signal_request_rejects_uuid_wrong_variant_c() -> None: - """UUID with variant 'c' (1100 bits) must be rejected — only 8/9/a/b allowed.""" - with pytest.raises(ValidationError): - SignalRequest(user_id="550e8400-e29b-41d4-c716-446655440000", timestamp=1700000000000) +def test_signal_request_without_user_id() -> None: + """SignalRequest works without user_id (JWT auth mode).""" + req = SignalRequest(timestamp=1700000000000) + assert req.user_id is None def test_register_request_accepts_all_valid_v4_variants() -> None: diff --git a/tests/test_models.py b/tests/test_models.py index 0e55586..cf9641a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -123,14 +123,16 @@ def test_signal_request_no_geo(): assert req.geo is None -def test_signal_request_missing_user_id(): - with pytest.raises(ValidationError): - SignalRequest(timestamp=1742478000000) # type: ignore[call-arg] +def test_signal_request_without_user_id(): + """user_id is optional (JWT auth sends signals without it).""" + req = SignalRequest(timestamp=1742478000000) + assert req.user_id is None def test_signal_request_empty_user_id(): - with pytest.raises(ValidationError): - SignalRequest(user_id="", timestamp=1742478000000) + """Empty string user_id is accepted (treated as None at endpoint level).""" + req = SignalRequest(user_id="", timestamp=1742478000000) + assert req.user_id == "" def test_signal_request_timestamp_zero(): diff --git a/tests/test_signal.py b/tests/test_signal.py index 1ed0fc2..ca9d547 100644 --- a/tests/test_signal.py +++ b/tests/test_signal.py @@ -78,14 +78,14 @@ async def test_signal_without_geo_success(): @pytest.mark.asyncio -async def test_signal_missing_user_id_returns_422(): - """Missing user_id field must return 422.""" +async def test_signal_missing_auth_returns_401(): + """Missing Authorization header must return 401.""" async with make_app_client() as client: resp = await client.post( "/api/signal", json={"timestamp": 1742478000000}, ) - assert resp.status_code == 422 + assert resp.status_code == 401 @pytest.mark.asyncio