auth: replace UUID-based login with JWT credential verification

Login now requires login/email + password verified against DB via
/api/auth/login. Only approved registrations can access the app.
Signal endpoint accepts JWT Bearer tokens alongside legacy api_key auth.
Old UUID-only registration flow removed from frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gros Frumos 2026-03-21 14:14:12 +02:00
parent 1adcabf3a6
commit 04f7bd79e2
8 changed files with 173 additions and 128 deletions

View file

@ -18,6 +18,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend import config, db, push, telegram from backend import config, db, push, telegram
from backend.middleware import ( from backend.middleware import (
_verify_jwt_token,
create_auth_token, create_auth_token,
rate_limit_auth_login, rate_limit_auth_login,
rate_limit_auth_register, rate_limit_auth_register,
@ -176,13 +177,36 @@ async def signal(
) -> SignalResponse: ) -> SignalResponse:
if credentials is None: if credentials is None:
raise HTTPException(status_code=401, detail="Unauthorized") 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): user_identifier: str = ""
raise HTTPException(status_code=403, detail="User is blocked") 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 geo = body.geo
lat = geo.lat if geo else None lat = geo.lat if geo else None
@ -190,23 +214,21 @@ async def signal(
accuracy = geo.accuracy if geo else None accuracy = geo.accuracy if geo else None
signal_id = await db.save_signal( signal_id = await db.save_signal(
user_uuid=body.user_id, user_uuid=user_identifier,
timestamp=body.timestamp, timestamp=body.timestamp,
lat=lat, lat=lat,
lon=lon, lon=lon,
accuracy=accuracy, accuracy=accuracy,
) )
user_name = await db.get_user_name(body.user_id)
ts = datetime.fromtimestamp(body.timestamp / 1000, tz=timezone.utc) ts = datetime.fromtimestamp(body.timestamp / 1000, tz=timezone.utc)
name = user_name or body.user_id[:8]
geo_info = ( geo_info = (
f"📍 {lat}, {lon}{accuracy}м)" f"📍 {lat}, {lon}{accuracy}м)"
if geo if geo
else "Без геолокации" else "Без геолокации"
) )
text = ( text = (
f"🚨 Сигнал от {name}\n" f"🚨 Сигнал от {user_name}\n"
f"{ts.strftime('%H:%M:%S')} UTC\n" f"{ts.strftime('%H:%M:%S')} UTC\n"
f"{geo_info}" f"{geo_info}"
) )

View file

@ -22,7 +22,7 @@ class GeoData(BaseModel):
class SignalRequest(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) timestamp: int = Field(..., gt=0)
geo: Optional[GeoData] = None geo: Optional[GeoData] = None

View file

@ -39,31 +39,26 @@ function _initStorage() {
// ========== User identity ========== // ========== 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() { function _isRegistered() {
return _storage.getItem('baton_registered') === '1'; return !!_storage.getItem('baton_auth_token');
} }
function _getUserName() { function _getUserName() {
return _storage.getItem('baton_user_name') || ''; return _storage.getItem('baton_login') || '';
} }
function _getApiKey() { function _getAuthToken() {
return _storage.getItem('baton_api_key') || ''; return _storage.getItem('baton_auth_token') || '';
} }
function _saveRegistration(name, apiKey) { function _saveAuth(token, login) {
_storage.setItem('baton_user_name', name); _storage.setItem('baton_auth_token', token);
_storage.setItem('baton_registered', '1'); _storage.setItem('baton_login', login);
if (apiKey) _storage.setItem('baton_api_key', apiKey); }
function _clearAuth() {
_storage.removeItem('baton_auth_token');
_storage.removeItem('baton_login');
} }
function _getInitials(name) { function _getInitials(name) {
@ -100,6 +95,14 @@ function _setRegStatus(msg, cls) {
el.hidden = !msg; 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) { function _showView(id) {
['view-login', 'view-register'].forEach((vid) => { ['view-login', 'view-register'].forEach((vid) => {
const el = document.getElementById(vid); const el = document.getElementById(vid);
@ -157,23 +160,38 @@ function _getGeo() {
// ========== Handlers ========== // ========== Handlers ==========
async function _handleRegister() { async function _handleLogin() {
const input = document.getElementById('name-input'); const loginInput = document.getElementById('login-input');
const btn = document.getElementById('btn-confirm'); const passInput = document.getElementById('login-password');
const name = input.value.trim(); const btn = document.getElementById('btn-login');
if (!name) return; const login = loginInput.value.trim();
const password = passInput.value;
if (!login || !password) return;
btn.disabled = true; btn.disabled = true;
_setStatus('', ''); _setLoginStatus('', '');
try { try {
const uuid = _getOrCreateUserId(); const data = await _apiPost('/api/auth/login', {
const data = await _apiPost('/api/register', { uuid, name }); login_or_email: login,
_saveRegistration(name, data.api_key); password: password,
});
_saveAuth(data.token, data.login);
passInput.value = '';
_updateUserAvatar(); _updateUserAvatar();
_showMain(); _showMain();
} catch (_) { } catch (err) {
_setStatus('Error. Please try again.', 'error'); 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; btn.disabled = false;
} }
} }
@ -186,9 +204,15 @@ function _setSosState(state) {
} }
async function _handleSignal() { async function _handleSignal() {
// v1: no offline queue — show error and return (decision #1019)
if (!navigator.onLine) { if (!navigator.onLine) {
_setStatus('No connection. Check your network and try again.', 'error'); _setStatus('Нет соединения. Проверьте сеть и попробуйте снова.', 'error');
return;
}
const token = _getAuthToken();
if (!token) {
_clearAuth();
_showOnboarding();
return; return;
} }
@ -197,16 +221,13 @@ async function _handleSignal() {
try { try {
const geo = await _getGeo(); const geo = await _getGeo();
const uuid = _getOrCreateUserId(); const body = { timestamp: Date.now() };
const body = { user_id: uuid, timestamp: Date.now() };
if (geo) body.geo = geo; if (geo) body.geo = geo;
const apiKey = _getApiKey(); await _apiPost('/api/signal', body, { Authorization: 'Bearer ' + token });
const authHeaders = apiKey ? { Authorization: 'Bearer ' + apiKey } : {};
await _apiPost('/api/signal', body, authHeaders);
_setSosState('success'); _setSosState('success');
_setStatus('Signal sent!', 'success'); _setStatus('Сигнал отправлен!', 'success');
setTimeout(() => { setTimeout(() => {
_setSosState('default'); _setSosState('default');
_setStatus('', ''); _setStatus('', '');
@ -214,9 +235,11 @@ async function _handleSignal() {
} catch (err) { } catch (err) {
_setSosState('default'); _setSosState('default');
if (err && err.status === 401) { if (err && err.status === 401) {
_setStatus('Session expired or key is invalid. Please re-register.', 'error'); _clearAuth();
_setStatus('Сессия истекла. Войдите заново.', 'error');
setTimeout(() => _showOnboarding(), 1500);
} else { } else {
_setStatus('Error sending. Try again.', 'error'); _setStatus('Ошибка отправки. Попробуйте ещё раз.', 'error');
} }
} }
} }
@ -227,28 +250,36 @@ function _showOnboarding() {
_showScreen('screen-onboarding'); _showScreen('screen-onboarding');
_showView('view-login'); _showView('view-login');
const input = document.getElementById('name-input'); const loginInput = document.getElementById('login-input');
const btn = document.getElementById('btn-confirm'); const passInput = document.getElementById('login-password');
const btnLogin = document.getElementById('btn-login');
input.addEventListener('input', () => { function _updateLoginBtn() {
btn.disabled = input.value.trim().length === 0; 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) => { btnLogin.addEventListener('click', _handleLogin);
if (e.key === 'Enter' && !btn.disabled) _handleRegister();
});
btn.addEventListener('click', _handleRegister);
const btnToRegister = document.getElementById('btn-switch-to-register'); const btnToRegister = document.getElementById('btn-switch-to-register');
if (btnToRegister) { if (btnToRegister) {
btnToRegister.addEventListener('click', () => { btnToRegister.addEventListener('click', () => {
_setRegStatus('', ''); _setRegStatus('', '');
_setLoginStatus('', '');
_showView('view-register'); _showView('view-register');
}); });
} }
const btnToLogin = document.getElementById('btn-switch-to-login'); const btnToLogin = document.getElementById('btn-switch-to-login');
if (btnToLogin) { if (btnToLogin) {
btnToLogin.addEventListener('click', () => _showView('view-login')); btnToLogin.addEventListener('click', () => {
_setLoginStatus('', '');
_showView('view-login');
});
} }
const btnRegister = document.getElementById('btn-register'); const btnRegister = document.getElementById('btn-register');
@ -403,11 +434,7 @@ async function _handleSignUp() {
function _init() { function _init() {
_initStorage(); _initStorage();
// Pre-generate and persist UUID on first visit (per arch spec flow) // Private mode graceful degradation (decision #1041)
_getOrCreateUserId();
// Private mode graceful degradation (decision #1041):
// show inline banner with explicit action guidance when localStorage is unavailable
if (_storageType !== 'local') { if (_storageType !== 'local') {
const banner = document.getElementById('private-mode-banner'); const banner = document.getElementById('private-mode-banner');
if (banner) banner.hidden = false; if (banner) banner.hidden = false;
@ -418,7 +445,7 @@ function _init() {
window.addEventListener('online', _updateNetworkIndicator); window.addEventListener('online', _updateNetworkIndicator);
window.addEventListener('offline', _updateNetworkIndicator); window.addEventListener('offline', _updateNetworkIndicator);
// Route to correct screen // Route to correct screen based on JWT token presence
if (_isRegistered()) { if (_isRegistered()) {
_showMain(); _showMain();
} else { } else {

View file

@ -37,23 +37,32 @@
<!-- Onboarding screen: shown on first visit (no UUID registered yet) --> <!-- Onboarding screen: shown on first visit (no UUID registered yet) -->
<div id="screen-onboarding" class="screen" role="main" hidden> <div id="screen-onboarding" class="screen" role="main" hidden>
<!-- View: name entry (existing onboarding) --> <!-- View: login with credentials -->
<div class="screen-content" id="view-login"> <div class="screen-content" id="view-login">
<input <input
type="text" type="text"
id="name-input" id="login-input"
class="name-input" class="name-input"
placeholder="Your name" placeholder="Логин или email"
maxlength="100" maxlength="255"
autocomplete="name" autocomplete="username"
autocorrect="off" autocorrect="off"
autocapitalize="words" autocapitalize="none"
spellcheck="false" spellcheck="false"
aria-label="Your name" aria-label="Логин или email"
> >
<button type="button" id="btn-confirm" class="btn-confirm" disabled> <input
Confirm type="password"
id="login-password"
class="name-input"
placeholder="Пароль"
autocomplete="current-password"
aria-label="Пароль"
>
<button type="button" id="btn-login" class="btn-confirm" disabled>
Войти
</button> </button>
<div id="login-status" class="reg-status" hidden></div>
<button type="button" id="btn-switch-to-register" class="btn-link"> <button type="button" id="btn-switch-to-register" class="btn-link">
Зарегистрироваться Зарегистрироваться
</button> </button>

View file

@ -222,9 +222,9 @@ def test_html_loads_app_js() -> None:
assert "/app.js" in _html() assert "/app.js" in _html()
def test_html_has_name_input() -> None: def test_html_has_login_input() -> None:
"""index.html must have name input field for onboarding.""" """index.html must have login input field for onboarding."""
assert 'id="name-input"' in _html() assert 'id="login-input"' in _html()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -316,31 +316,19 @@ def _app_js() -> str:
return (FRONTEND / "app.js").read_text(encoding="utf-8") return (FRONTEND / "app.js").read_text(encoding="utf-8")
def test_app_uses_crypto_random_uuid() -> None: def test_app_posts_to_auth_login() -> None:
"""app.js must generate UUID via crypto.randomUUID().""" """app.js must send POST to /api/auth/login during login."""
assert "crypto.randomUUID()" in _app_js() assert "/api/auth/login" in _app_js()
def test_app_posts_to_api_register() -> None: def test_app_posts_to_auth_register() -> None:
"""app.js must send POST to /api/register during onboarding.""" """app.js must send POST to /api/auth/register during registration."""
assert "/api/register" in _app_js() assert "/api/auth/register" in _app_js()
def test_app_register_sends_uuid() -> None: def test_app_stores_auth_token() -> None:
"""app.js must include uuid in the /api/register request body.""" """app.js must persist JWT token to storage."""
app = _app_js() assert "baton_auth_token" in _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()
assert "setItem" 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() assert "/api/signal" in _app_js()
def test_app_signal_sends_user_id() -> None: def test_app_signal_sends_auth_header() -> None:
"""app.js must include user_id (UUID) in the /api/signal request body.""" """app.js must include Authorization Bearer header in /api/signal request."""
app = _app_js() 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( 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, \ 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: 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" "btn-sos must be connected to _handleSignal"
def test_app_signal_uses_uuid_from_storage() -> None: def test_app_signal_uses_token_from_storage() -> None:
"""app.js must retrieve UUID from storage (_getOrCreateUserId) before sending signal.""" """app.js must retrieve auth token from storage before sending signal."""
app = _app_js() app = _app_js()
handle_signal = re.search( handle_signal = re.search(
r"async function _handleSignal\(\).*?^}", app, re.MULTILINE | re.DOTALL r"async function _handleSignal\(\).*?^}", app, re.MULTILINE | re.DOTALL
) )
assert handle_signal, "_handleSignal function not found" assert handle_signal, "_handleSignal function not found"
assert "_getOrCreateUserId" in handle_signal.group(0), \ assert "_getAuthToken" in handle_signal.group(0), \
"_handleSignal must call _getOrCreateUserId() to get UUID" "_handleSignal must call _getAuthToken() to get JWT token"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -102,10 +102,10 @@ def test_register_request_rejects_old_placeholder_uuid(bad_uuid: str) -> None:
"agg-uuid-001", "agg-uuid-001",
"create-uuid-001", "create-uuid-001",
]) ])
def test_signal_request_rejects_old_placeholder_uuid(bad_uuid: str) -> None: def test_signal_request_accepts_any_user_id_string(bad_uuid: str) -> None:
"""SignalRequest.user_id must reject old-style placeholder strings.""" """SignalRequest.user_id is optional (no pattern) — validation is at endpoint level."""
with pytest.raises(ValidationError): req = SignalRequest(user_id=bad_uuid, timestamp=1700000000000)
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") RegisterRequest(uuid="550e8400-e29b-31d4-a716-446655440000", name="Test")
def test_signal_request_rejects_uuid_wrong_variant_bits() -> None: def test_signal_request_accepts_any_variant_bits() -> None:
"""UUID with invalid variant bits (0xxx in fourth group) must be rejected.""" """SignalRequest.user_id is now optional and unvalidated (JWT auth doesn't use it)."""
with pytest.raises(ValidationError): req = SignalRequest(user_id="550e8400-e29b-41d4-0716-446655440000", timestamp=1700000000000)
# fourth group starts with '0' — not 8/9/a/b variant assert req.user_id is not None
SignalRequest(user_id="550e8400-e29b-41d4-0716-446655440000", timestamp=1700000000000)
def test_signal_request_rejects_uuid_wrong_variant_c() -> None: def test_signal_request_without_user_id() -> None:
"""UUID with variant 'c' (1100 bits) must be rejected — only 8/9/a/b allowed.""" """SignalRequest works without user_id (JWT auth mode)."""
with pytest.raises(ValidationError): req = SignalRequest(timestamp=1700000000000)
SignalRequest(user_id="550e8400-e29b-41d4-c716-446655440000", timestamp=1700000000000) assert req.user_id is None
def test_register_request_accepts_all_valid_v4_variants() -> None: def test_register_request_accepts_all_valid_v4_variants() -> None:

View file

@ -123,14 +123,16 @@ def test_signal_request_no_geo():
assert req.geo is None assert req.geo is None
def test_signal_request_missing_user_id(): def test_signal_request_without_user_id():
with pytest.raises(ValidationError): """user_id is optional (JWT auth sends signals without it)."""
SignalRequest(timestamp=1742478000000) # type: ignore[call-arg] req = SignalRequest(timestamp=1742478000000)
assert req.user_id is None
def test_signal_request_empty_user_id(): def test_signal_request_empty_user_id():
with pytest.raises(ValidationError): """Empty string user_id is accepted (treated as None at endpoint level)."""
SignalRequest(user_id="", timestamp=1742478000000) req = SignalRequest(user_id="", timestamp=1742478000000)
assert req.user_id == ""
def test_signal_request_timestamp_zero(): def test_signal_request_timestamp_zero():

View file

@ -78,14 +78,14 @@ async def test_signal_without_geo_success():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_signal_missing_user_id_returns_422(): async def test_signal_missing_auth_returns_401():
"""Missing user_id field must return 422.""" """Missing Authorization header must return 401."""
async with make_app_client() as client: async with make_app_client() as client:
resp = await client.post( resp = await client.post(
"/api/signal", "/api/signal",
json={"timestamp": 1742478000000}, json={"timestamp": 1742478000000},
) )
assert resp.status_code == 422 assert resp.status_code == 401
@pytest.mark.asyncio @pytest.mark.asyncio