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:
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