diff --git a/Security-audit.md b/Security-audit.md index f1efcab..b2919de 100644 --- a/Security-audit.md +++ b/Security-audit.md @@ -97,7 +97,7 @@ These findings are prioritized below. Severity describes the potential security **Priority:** Completed for the single-instance SQLite deployment; distributed limiting and reset-mail controls remain. -### SA-006: Firefox extension has broad host access and stores bearer tokens in local storage + **Severity:** High **Evidence:** `webextension/manifest.json` declares `host_permissions: [""]`; `webextension/options.js` and `webextension/popup.js` store and retrieve `accessToken` through `browser.storage.local`. diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index 7221178..cec1387 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -1014,5 +1014,11 @@ Use the VIBE directory to log interactions ### Assistant outcome Appended this visible interaction to the project VIBE chat and prompt logs in the repo’s append-only format. +### User +First implement a proper refresh-token rotation flow with hashing, revocation, and device binding + +### Assistant outcome +Added a refresh-token rotation flow with hashed token storage, per-device binding, token-family revocation, and a dedicated /api/auth/refresh endpoint. The new flow was validated with a focused regression test covering replay rejection and device mismatch rejection. + ### Assistant outcome Made popup state transitions defensive by synchronizing both `hidden` attributes and CSS classes. Non-authentication tag-loading errors no longer switch the popup to signed-out state; only a rejected session does. Added `display: none !important` guards for both authentication blocks. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index b98e905..4d38aaa 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -181,6 +181,7 @@ 161. The popup still shows the sign-in block even though the authenticated session text is displayed; show the block only when signed out. 162. The authenticated session text and sign-in block are still shown together. 163. Use the VIBE directory to log interactions. +164. First implement a proper refresh-token rotation flow with hashing, revocation, and device binding. ## Future entries diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 621e446..7c88a88 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -13,7 +13,7 @@ from backend.app.services.auth_service import authenticate_user, find_user from backend.app.services.email_service import send_password_reset_email, smtp_configured from backend.app.services.email_verification import verify_email from backend.app.services.password_reset import create_reset_token, reset_password -from backend.app.services.token_service import issue_token, revoke_token, validate_token +from backend.app.services.token_service import issue_token, revoke_token, rotate_refresh_token, validate_token from backend.app.services.otp_service import verify_code from backend.app.services.secret_store import decrypt_secret from backend.app.services.email_addresses import verify_user_email_address @@ -28,6 +28,12 @@ class LoginRequest(BaseModel): email: str password: str otp: str | None = None + device_id: str | None = None + + +class RefreshTokenRequest(BaseModel): + refresh_token: str + device_id: str | None = None class PasswordResetRequest(BaseModel): @@ -62,16 +68,32 @@ def login(payload: LoginRequest, request: Request): raise HTTPException(status_code=401, detail='One-time password required or invalid') clear_login_failures(ip_address, email) - token_data = issue_token(user['id'], user['username']) + token_data = issue_token(user['id'], user['username'], payload.device_id) return { 'access_token': token_data['access_token'], 'token_type': 'bearer', 'expires_at': token_data['expires_at'], 'refresh_token': token_data['refresh_token'], + 'device_id': token_data['device_id'], 'user': {'id': user['id'], 'username': user['username'], 'email': user['email'], 'otp_enabled': bool(user['otp_enabled'])} } +@router.post('/refresh') +def refresh_token_endpoint(payload: RefreshTokenRequest): + rotated = rotate_refresh_token(payload.refresh_token, payload.device_id) + if rotated is None: + raise HTTPException(status_code=401, detail='Refresh token is invalid, expired, or bound to another device') + return { + 'access_token': rotated['access_token'], + 'token_type': 'bearer', + 'expires_at': rotated['expires_at'], + 'refresh_token': rotated['refresh_token'], + 'device_id': rotated['device_id'], + 'user': {'id': rotated['user_id'], 'username': rotated['username']}, + } + + @router.get('/verify-email') def verify_email_address(token: str): if not verify_email(token): diff --git a/backend/app/database.py b/backend/app/database.py index ed0fdd0..db4e7f6 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -226,6 +226,12 @@ CREATE TABLE IF NOT EXISTS pending_primary_email_changes ( '''), (14, ''' DROP TABLE IF EXISTS pending_primary_email_changes; +'''), + (15, ''' +ALTER TABLE tokens ADD COLUMN device_id TEXT; +ALTER TABLE tokens ADD COLUMN token_family_id TEXT; +CREATE INDEX IF NOT EXISTS idx_tokens_device_id ON tokens(device_id); +CREATE INDEX IF NOT EXISTS idx_tokens_family_id ON tokens(token_family_id); ''') ] diff --git a/backend/app/services/token_service.py b/backend/app/services/token_service.py index 2fe9af3..1d3da82 100644 --- a/backend/app/services/token_service.py +++ b/backend/app/services/token_service.py @@ -1,7 +1,7 @@ ## Copyright © 2026 Olaf Kolkman ## SPDX-License-Identifier: GPL-3.0-or-later -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from hashlib import sha256 from uuid import uuid4 @@ -13,53 +13,125 @@ def hash_token(token: str) -> str: return sha256(token.encode('utf-8')).hexdigest() -def issue_token(user_id: str, username: str) -> dict: - token = f'token-{username}-{uuid4().hex}' - expires_at = datetime.now(timezone.utc).replace(microsecond=0) - expires_at = expires_at.replace(day=expires_at.day + 30 if False else expires_at.day) - # one-month expiry, held as a configured value in settings - from datetime import timedelta - expires_at = datetime.now(timezone.utc) + timedelta(days=settings.token_expiry_days) +def _token_expiry() -> datetime: + return datetime.now(timezone.utc) + timedelta(days=settings.token_expiry_days) + + +def _persist_token(conn, user_id: str, token: str, token_type: str, expires_at: datetime, device_id: str | None, family_id: str | None) -> None: + conn.execute( + ''' + INSERT INTO tokens (id, user_id, token_hash, token_type, expires_at, created_at, revoked, device_id, token_family_id) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 0, ?, ?) + ''', + (str(uuid4()), user_id, hash_token(token), token_type, expires_at.isoformat(), device_id, family_id), + ) + + +def issue_token(user_id: str, username: str, device_id: str | None = None) -> dict: + if device_id is None or not device_id.strip(): + device_id = f'device-{uuid4().hex}' + device_id = device_id.strip() + token_family_id = str(uuid4()) + access_token = f'token-{username}-{uuid4().hex}' + refresh_token = f'refresh-{username}-{uuid4().hex}' + access_expires_at = _token_expiry() + refresh_expires_at = access_expires_at + timedelta(days=30) with get_connection() as conn: - conn.execute( - ''' - INSERT INTO tokens (id, user_id, token_hash, token_type, expires_at, created_at, revoked) - VALUES (?, ?, ?, 'access', ?, CURRENT_TIMESTAMP, 0) - ''', - (str(uuid4()), user_id, hash_token(token), expires_at.isoformat()) - ) + _persist_token(conn, user_id, access_token, 'access', access_expires_at, device_id, token_family_id) + _persist_token(conn, user_id, refresh_token, 'refresh', refresh_expires_at, device_id, token_family_id) conn.commit() return { - 'access_token': token, + 'access_token': access_token, 'token_type': 'bearer', - 'expires_at': expires_at.isoformat(), - 'refresh_token': f'refresh-{uuid4().hex}', + 'expires_at': access_expires_at.isoformat(), + 'refresh_token': refresh_token, + 'device_id': device_id, + 'token_family_id': token_family_id, } def validate_token(token: str) -> dict | None: token_hash = hash_token(token) + now = datetime.now(timezone.utc).isoformat() with get_connection() as conn: row = conn.execute( ''' SELECT * FROM tokens - WHERE token_hash = ? AND revoked = 0 AND expires_at > ? + WHERE token_hash = ? AND token_type = 'access' AND revoked = 0 AND expires_at > ? ''', - (token_hash, datetime.now(timezone.utc).isoformat()), + (token_hash, now), ).fetchone() if row is None: return None return dict(row) -def revoke_token(token: str) -> bool: +def validate_refresh_token(token: str, device_id: str | None = None) -> dict | None: + token_hash = hash_token(token) + now = datetime.now(timezone.utc).isoformat() + with get_connection() as conn: + row = conn.execute( + ''' + SELECT * FROM tokens + WHERE token_hash = ? AND token_type = 'refresh' AND revoked = 0 AND expires_at > ? + AND (? IS NULL OR device_id = ?) + ''', + (token_hash, now, device_id, device_id), + ).fetchone() + if row is None: + return None + return dict(row) + + +def rotate_refresh_token(refresh_token: str, device_id: str | None = None) -> dict | None: + current = validate_refresh_token(refresh_token, device_id) + if current is None: + return None + + family_id = current.get('token_family_id') or current['id'] + user_id = current['user_id'] + with get_connection() as conn: + user = conn.execute('SELECT username FROM users WHERE id = ?', (user_id,)).fetchone() + if user is None: + return None + + conn.execute( + 'UPDATE tokens SET revoked = 1 WHERE token_family_id = ? AND token_type = ? AND revoked = 0', + (family_id, 'refresh'), + ) + conn.execute( + 'UPDATE tokens SET revoked = 1 WHERE id = ?', + (current['id'],), + ) + + new_access = f'token-{user["username"]}-{uuid4().hex}' + new_refresh = f'refresh-{user["username"]}-{uuid4().hex}' + access_expires_at = _token_expiry() + refresh_expires_at = access_expires_at + timedelta(days=30) + + _persist_token(conn, user_id, new_access, 'access', access_expires_at, device_id, family_id) + _persist_token(conn, user_id, new_refresh, 'refresh', refresh_expires_at, device_id, family_id) + conn.commit() + + return { + 'access_token': new_access, + 'token_type': 'bearer', + 'expires_at': access_expires_at.isoformat(), + 'refresh_token': new_refresh, + 'device_id': device_id, + 'user_id': user_id, + 'username': user['username'], + } + + +def revoke_token(token: str, token_type: str = 'access') -> bool: token_hash = hash_token(token) with get_connection() as conn: cursor = conn.execute( - 'UPDATE tokens SET revoked = 1 WHERE token_hash = ?', - (token_hash,), + 'UPDATE tokens SET revoked = 1 WHERE token_hash = ? AND token_type = ?', + (token_hash, token_type), ) conn.commit() return cursor.rowcount > 0 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 6925f3d..b5188ae 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -68,6 +68,40 @@ def test_login_rate_limit_locks_out_after_five_failures_and_resets_on_success(): assert valid.status_code == 200 +def test_refresh_token_rotation_binds_to_device_and_revokes_old_tokens(): + device_id = f'device-{uuid4().hex}' + login = client.post('/api/auth/login', json={ + 'email': 'alice@example.com', + 'password': 'secret123', + 'device_id': device_id, + }) + assert login.status_code == 200 + refresh_token = login.json()['refresh_token'] + first_access = login.json()['access_token'] + + rotated = client.post('/api/auth/refresh', json={ + 'refresh_token': refresh_token, + 'device_id': device_id, + }) + assert rotated.status_code == 200 + rotated_payload = rotated.json() + assert rotated_payload['access_token'] != first_access + assert rotated_payload['refresh_token'] != refresh_token + assert rotated_payload['device_id'] == device_id + + replay = client.post('/api/auth/refresh', json={ + 'refresh_token': refresh_token, + 'device_id': device_id, + }) + assert replay.status_code == 401 + + wrong_device = client.post('/api/auth/refresh', json={ + 'refresh_token': rotated_payload['refresh_token'], + 'device_id': 'device-other', + }) + assert wrong_device.status_code == 401 + + def test_password_hashes_are_salted_and_legacy_hashes_upgrade_on_login(): from hashlib import sha256 from backend.app.database import hash_password