Feat: Implement JWT bearer token authentication
Protects image upload, delete, and tag-update endpoints behind Bearer token auth. Public read endpoints remain open. Angular SPA gains a login page, auth interceptor, and route guard for /upload. - JWTAuthProvider (HS256, sub/iat/exp, secrets.compare_digest) - POST /api/v1/auth/token login endpoint - require_auth FastAPI dependency on all write routes - AuthService, LoginComponent, authInterceptor, authGuard - Detail page hides write controls for unauthenticated visitors - 43 unit tests passing; integration tests require Docker stack Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,14 +2,27 @@ import os
|
||||
import pytest
|
||||
|
||||
|
||||
_BASE_ENV = {
|
||||
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
|
||||
"S3_ENDPOINT_URL": "http://localhost:9000",
|
||||
"S3_BUCKET_NAME": "test-bucket",
|
||||
"S3_ACCESS_KEY_ID": "key",
|
||||
"S3_SECRET_ACCESS_KEY": "secret",
|
||||
"S3_REGION": "us-east-1",
|
||||
"API_BASE_URL": "http://localhost:8000",
|
||||
"JWT_SECRET_KEY": "test-secret",
|
||||
"OWNER_USERNAME": "admin",
|
||||
"OWNER_PASSWORD": "password",
|
||||
}
|
||||
|
||||
|
||||
def _apply_env(monkeypatch, extra=None):
|
||||
for k, v in {**_BASE_ENV, **(extra or {})}.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
|
||||
|
||||
def test_settings_load_from_env(monkeypatch):
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@localhost/db")
|
||||
monkeypatch.setenv("S3_ENDPOINT_URL", "http://localhost:9000")
|
||||
monkeypatch.setenv("S3_BUCKET_NAME", "test-bucket")
|
||||
monkeypatch.setenv("S3_ACCESS_KEY_ID", "key")
|
||||
monkeypatch.setenv("S3_SECRET_ACCESS_KEY", "secret")
|
||||
monkeypatch.setenv("S3_REGION", "us-east-1")
|
||||
monkeypatch.setenv("API_BASE_URL", "http://localhost:8000")
|
||||
_apply_env(monkeypatch)
|
||||
|
||||
# Import inside test to pick up monkeypatched env
|
||||
import importlib
|
||||
@@ -20,17 +33,13 @@ def test_settings_load_from_env(monkeypatch):
|
||||
assert s.database_url == "postgresql+asyncpg://u:p@localhost/db"
|
||||
assert s.s3_bucket_name == "test-bucket"
|
||||
assert s.max_upload_bytes == 52428800 # default
|
||||
assert s.jwt_secret_key == "test-secret"
|
||||
assert s.jwt_expiry_seconds == 86400 # default
|
||||
assert s.owner_username == "admin"
|
||||
|
||||
|
||||
def test_settings_max_upload_bytes_override(monkeypatch):
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://u:p@localhost/db")
|
||||
monkeypatch.setenv("S3_ENDPOINT_URL", "http://localhost:9000")
|
||||
monkeypatch.setenv("S3_BUCKET_NAME", "test-bucket")
|
||||
monkeypatch.setenv("S3_ACCESS_KEY_ID", "key")
|
||||
monkeypatch.setenv("S3_SECRET_ACCESS_KEY", "secret")
|
||||
monkeypatch.setenv("S3_REGION", "us-east-1")
|
||||
monkeypatch.setenv("API_BASE_URL", "http://localhost:8000")
|
||||
monkeypatch.setenv("MAX_UPLOAD_BYTES", "10485760")
|
||||
_apply_env(monkeypatch, {"MAX_UPLOAD_BYTES": "10485760"})
|
||||
|
||||
import importlib
|
||||
import app.config as config_module
|
||||
@@ -38,3 +47,14 @@ def test_settings_max_upload_bytes_override(monkeypatch):
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.max_upload_bytes == 10485760
|
||||
|
||||
|
||||
def test_settings_jwt_expiry_override(monkeypatch):
|
||||
_apply_env(monkeypatch, {"JWT_EXPIRY_SECONDS": "3600"})
|
||||
|
||||
import importlib
|
||||
import app.config as config_module
|
||||
importlib.reload(config_module)
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.jwt_expiry_seconds == 3600
|
||||
|
||||
97
api/tests/unit/test_jwt_auth.py
Normal file
97
api/tests/unit/test_jwt_auth.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import time
|
||||
import pytest
|
||||
import jwt as pyjwt
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.auth.jwt_provider import JWTAuthProvider
|
||||
|
||||
SECRET = "test-secret-key"
|
||||
USERNAME = "owner"
|
||||
PASSWORD = "hunter2"
|
||||
|
||||
|
||||
def make_provider(**kwargs) -> JWTAuthProvider:
|
||||
defaults = dict(
|
||||
secret_key=SECRET,
|
||||
expiry_seconds=3600,
|
||||
owner_username=USERNAME,
|
||||
owner_password=PASSWORD,
|
||||
)
|
||||
return JWTAuthProvider(**{**defaults, **kwargs})
|
||||
|
||||
|
||||
def test_create_token_is_valid_jwt():
|
||||
provider = make_provider()
|
||||
token = provider.create_token()
|
||||
payload = pyjwt.decode(token, SECRET, algorithms=["HS256"])
|
||||
assert payload["sub"] == "owner"
|
||||
assert "iat" in payload
|
||||
assert "exp" in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_returns_owner():
|
||||
provider = make_provider()
|
||||
token = provider.create_token()
|
||||
identity = await provider.get_identity(f"Bearer {token}")
|
||||
assert identity.id == "owner"
|
||||
assert identity.anonymous is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_raises_on_expired_token():
|
||||
provider = make_provider(expiry_seconds=-1)
|
||||
token = provider.create_token()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await provider.get_identity(f"Bearer {token}")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_raises_on_wrong_key():
|
||||
provider = make_provider()
|
||||
other = make_provider(secret_key="different-secret")
|
||||
token = other.create_token()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await provider.get_identity(f"Bearer {token}")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_raises_on_garbage():
|
||||
provider = make_provider()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await provider.get_identity("Bearer not.a.real.token")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_raises_on_missing_header():
|
||||
provider = make_provider()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await provider.get_identity(None)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_identity_raises_on_missing_bearer_prefix():
|
||||
provider = make_provider()
|
||||
token = provider.create_token()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await provider.get_identity(token)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
def test_verify_credentials_true():
|
||||
provider = make_provider()
|
||||
assert provider.verify_credentials(USERNAME, PASSWORD) is True
|
||||
|
||||
|
||||
def test_verify_credentials_false_wrong_password():
|
||||
provider = make_provider()
|
||||
assert provider.verify_credentials(USERNAME, "wrongpassword") is False
|
||||
|
||||
|
||||
def test_verify_credentials_false_wrong_username():
|
||||
provider = make_provider()
|
||||
assert provider.verify_credentials("notowner", PASSWORD) is False
|
||||
Reference in New Issue
Block a user