Skip to content

Commit

Permalink
feat: authenticate symmetrically signed JWT with shared secret for br…
Browse files Browse the repository at this point in the history
…owser tests

KK-1168 KK-1194.

Remove obsolete Tunnistamo configurations and replace them with Keycloak
configurations.

Add a possibility to use a symmetrically signed JWT with a shared secret
in authentication process.
  • Loading branch information
nikomakela committed Jul 12, 2024
1 parent 9a7ddae commit 718d92c
Show file tree
Hide file tree
Showing 10 changed files with 489 additions and 32 deletions.
5 changes: 4 additions & 1 deletion .env.keycloak.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,7 @@ KUKKUU_TICKET_VERIFICATION_URL=http://localhost:3000/ticket-verification-endpoin
MAIL_MAILGUN_KEY
MAIL_MAILGUN_DOMAIN=hel.fi
MAIL_MAILGUN_API=https://api.eu.mailgun.net/v3
KUKKUU_NOTIFICATIONS_SHEET_ID=1TkdQsO50DHOg5pi1JhzudOL1GKpiK-V2DCIoAipKj-M
KUKKUU_NOTIFICATIONS_SHEET_ID=1TkdQsO50DHOg5pi1JhzudOL1GKpiK-V2DCIoAipKj-M
TOKEN_AUTH_BROWSER_TEST_ENABLED=1
TOKEN_AUTH_BROWSER_TEST_JWT_256BIT_SIGN_SECRET=your-256-bit-secret
TOKEN_AUTH_BROWSER_TEST_JWT_ISSUER=https://kukkuu-ui.test.hel.ninja,https://kukkuu-admin-ui.test.hel.ninja
2 changes: 1 addition & 1 deletion browser-tests/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
BROWSER_TESTS_API_URL=https://localhost
BROWSER_TESTS_API_ADMIN_USER_NAME=admin
BROWSER_TESTS_API_ADMIN_PASSWORD=admin
BROWSER_TESTS_USER_NAME=kukkuu.[email protected]
BROWSER_TESTS_USER_NAME=kukkuu.[email protected]
BROWSER_TESTS_USER_PASSWORD=examplePassword
4 changes: 2 additions & 2 deletions kukkuu/middleware.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from helusers.oidc import RequestJWTAuthentication
from kukkuu.oidc import BrowserTestAwareJWTAuthentication


# copied from https://github.com/City-of-Helsinki/open-city-profile/blob/4f46f9f9f195c4254f79f5dfbd97d03b7fa87a5b/open_city_profile/middleware.py#L6 # noqa
Expand All @@ -9,7 +9,7 @@ def __init__(self, get_response):
def __call__(self, request):
if not request.user.is_authenticated:
try:
authenticator = RequestJWTAuthentication()
authenticator = BrowserTestAwareJWTAuthentication()
user_auth = authenticator.authenticate(request)
if user_auth is not None:
request.user_auth = user_auth
Expand Down
145 changes: 145 additions & 0 deletions kukkuu/oidc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import logging
from typing import Optional

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from helusers.authz import UserAuthorization
from helusers.jwt import JWT, ValidationError
from helusers.oidc import AuthenticationError, RequestJWTAuthentication
from helusers.settings import api_token_auth_settings
from helusers.user_utils import get_or_create_user
from jose import jwt as jose_jwt

from kukkuu.tests.utils.jwt_utils import is_valid_256_bit_key

logger = logging.getLogger(__name__)


class ApiTokenAuthSettings:
def __init__(self, **entries):
self.__dict__.update(entries)


class BrowserTestAwareJWTAuthentication(RequestJWTAuthentication):
def __init__(self):
super().__init__()
combined_settings = {
**api_token_auth_settings._settings,
**settings.OIDC_BROWSER_TEST_API_TOKEN_AUTH,
}
self._api_token_auth_settings = ApiTokenAuthSettings(**combined_settings)
self.algorithms = ["HS256"]

if self._api_token_auth_settings.ENABLED:
if not self._api_token_auth_settings.ISSUER:
raise ImproperlyConfigured(
"ISSUER must be configured when test JWT auth is enabled."
)
if not is_valid_256_bit_key(self._api_token_auth_settings.JWT_SIGN_SECRET):
raise ImproperlyConfigured(
"JWT_SIGN_SECRET (JWT secret key) must be 256 bits"
)

def _get_auth_header_jwt(self, request):
"""Looks for a JWT from the request's "Authorization" header.
If the header is not found, or it doesn't contain a JWT, returns None.
If the header is found and contains a JWT then returns a JWT.
Args:
request: the request object
Returns:
JWT|None: JWT if the Authorization header contains one. Otherwise None.
"""
auth_header = request.headers["Authorization"]

if not auth_header:
return None

auth_scheme, jwt_value = auth_header.split()
if auth_scheme.lower() != "bearer":
return None

return JWT(jwt_value, self._api_token_auth_settings)

def _validate_symmetrically_signed_jwt(self, jwt: JWT):
"""
Validate a symmetrically signed JWT that is signed by a shared secret.
NOTE: This function is implemented since the `django_helusers`
does not verify symmetrically signed JWT that are signed by a shared secret.
The `helusers` always uses a issuer specific `OIDCConfig` that fetches the
keys from a server (from a path "/.well-known/openid-configuration").
"""
logger.debug("Validating a symmetrically signed test JWT", extra=jwt)
try:
jwt.validate_issuer()
except ValidationError as e:
raise AuthenticationError(str(e)) from e
try:
jose_jwt.decode(
token=jwt._encoded_jwt,
key=self._api_token_auth_settings.JWT_SIGN_SECRET,
audience=jwt.claims.get("aud"),
issuer=jwt.claims.get("iss"),
subject=jwt.claims.get("sub"),
algorithms=self.algorithms,
)
except ValidationError as e:
raise AuthenticationError(str(e)) from e
except Exception:
raise AuthenticationError("JWT verification failed.")

def has_auth_token_for_testing(self, request) -> Optional[JWT]:
"""Checks whether the request contains a JWT which is
issued for end-to-end browser testing use only.
Args:
request: the request object.
Returns:
Optional[JWT]: JWT if it is issued for brower test use. Otherwise None.
"""
jwt = self._get_auth_header_jwt(request)
if jwt.claims.get("iss") not in self._api_token_auth_settings.ISSUER:
return None
return jwt

def authenticate_test_user(self, jwt: JWT):
"""Authenticate a user who is sending the browser test request.
Args:
jwt (JWT): the JWT issued for browser testing use that is
attached into the request.
Returns:
UserAuthorization: user authorization instance.
"""
logger.info("Authenticating with a test JWT!")
self._validate_symmetrically_signed_jwt(jwt)
logger.debug("The symmetrically signed JWT was valid.", extra=jwt)
user = get_or_create_user(jwt.claims, oidc=True)
logger.debug("The user %s returned from get_or_create_user", user, extra=user)
return UserAuthorization(user, jwt.claims)

def authenticate(self, request):
"""
Looks for a JWT from the request's "Authorization" header.
If the header is not found, or it doesn't contain a JWT, returns None.
If the header is found and contains a JWT then the JWT gets verified.
Test whether the JWT is issued for the end-to-end browser test use.
IF the JWT is for test use, then handle it with `authenticate_test_user`,
since the `django_helusers` does not support symmetrically signed JWT.
If verification passes, takes a user's id from the JWT's "sub" claim.
Creates a User if it doesn't already exist.
On success returns a UserAuthorization object.
Raises an AuthenticationError on authentication failure.
"""
if self._api_token_auth_settings.ENABLED:
if jwt := self.has_auth_token_for_testing(request):
return self.authenticate_test_user(jwt)
return super().authenticate(request)
52 changes: 38 additions & 14 deletions kukkuu/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from django.utils.translation import gettext_lazy as _
from sentry_sdk.integrations.django import DjangoIntegration

from kukkuu.tests.utils.jwt_utils import is_valid_256_bit_key

checkout_dir = environ.Path(__file__) - 2
assert os.path.exists(checkout_dir("manage.py"))

Expand Down Expand Up @@ -44,10 +46,13 @@
SENTRY_ENVIRONMENT=(str, ""),
CORS_ORIGIN_WHITELIST=(list, []),
CORS_ORIGIN_ALLOW_ALL=(bool, False),
TOKEN_AUTH_ACCEPTED_SCOPE_PREFIX=(str, "kukkuu"),
TOKEN_AUTH_REQUIRE_SCOPE_PREFIX=(bool, True),
TOKEN_AUTH_ACCEPTED_SCOPE_PREFIX=(str, ""),
TOKEN_AUTH_REQUIRE_SCOPE_PREFIX=(bool, False),
TOKEN_AUTH_ACCEPTED_AUDIENCE=(list, ["https://api.hel.fi/auth/kukkuu"]),
TOKEN_AUTH_AUTHSERVER_URL=(list, ["https://tunnistamo.test.hel.ninja/openid"]),
TOKEN_AUTH_AUTHSERVER_URL=(
list,
["https://tunnistus.test.hel.ninja/auth/realms/helsinkitunnistus"],
),
ILMOITIN_QUEUE_NOTIFICATIONS=(bool, True),
DEFAULT_FILE_STORAGE=(str, "django.core.files.storage.FileSystemStorage"),
GS_BUCKET_NAME=(str, ""),
Expand All @@ -71,18 +76,13 @@
VERIFICATION_TOKEN_LENGTH=(int, 8),
SUBSCRIPTIONS_AUTH_TOKEN_VALID_MINUTES=(int, 30 * 24 * 60), # 30 days
SUBSCRIPTIONS_AUTH_TOKEN_LENGTH=(int, 16),
# NOTE: TOKEN_AUTH_ACCEPTED_SCOPE_PREFIX sets a prefix
# for `GDPR_API_QUERY_SCOPE` and `GDPR_API_DELETE_SCOPE`.
GDPR_API_QUERY_SCOPE=(str, "kukkuu.gdprquery"),
GDPR_API_DELETE_SCOPE=(str, "kukkuu.gdprdelete"),
GDPR_API_AUTHORIZATION_FIELD=(str, "https://api.hel.fi/auth"),
# NOTE: For a Keycloak, the following GDPR variables are needed
# TOKEN_AUTH_ACCEPTED_SCOPE_PREFIX=(str, ""),
# TOKEN_AUTH_REQUIRE_SCOPE_PREFIX=(bool, False),
# GDPR_API_QUERY_SCOPE=(str, "gdprquery"),
# GDPR_API_DELETE_SCOPE=(str, "gdprdelete"),
# GDPR_API_AUTHORIZATION_FIELD=(str, "authorization.permissions.scopes"),
GDPR_API_QUERY_SCOPE=(str, "gdprquery"),
GDPR_API_DELETE_SCOPE=(str, "gdprdelete"),
GDPR_API_AUTHORIZATION_FIELD=(str, "authorization.permissions.scopes"),
HELUSERS_BACK_CHANNEL_LOGOUT_ENABLED=(bool, False),
TOKEN_AUTH_BROWSER_TEST_JWT_256BIT_SIGN_SECRET=(str, None),
TOKEN_AUTH_BROWSER_TEST_JWT_ISSUER=(list, None),
TOKEN_AUTH_BROWSER_TEST_ENABLED=(bool, False),
)

if os.path.exists(env_file):
Expand Down Expand Up @@ -276,6 +276,30 @@

OIDC_AUTH = {"OIDC_LEEWAY": 60 * 60}

OIDC_BROWSER_TEST_API_TOKEN_AUTH = {
"ENABLED": env.bool("TOKEN_AUTH_BROWSER_TEST_ENABLED"),
"JWT_SIGN_SECRET": env.str("TOKEN_AUTH_BROWSER_TEST_JWT_256BIT_SIGN_SECRET"),
"ISSUER": env.list("TOKEN_AUTH_BROWSER_TEST_JWT_ISSUER"),
"AUDIENCE": env.list("TOKEN_AUTH_ACCEPTED_AUDIENCE"),
"API_SCOPE_PREFIX": env.str("TOKEN_AUTH_ACCEPTED_SCOPE_PREFIX"),
"REQUIRE_API_SCOPE_FOR_AUTHENTICATION": env.bool("TOKEN_AUTH_REQUIRE_SCOPE_PREFIX"),
"API_AUTHORIZATION_FIELD": env.str("GDPR_API_AUTHORIZATION_FIELD"),
}

# Ensure that the browser test JWT authentication is configured properly.
if OIDC_BROWSER_TEST_API_TOKEN_AUTH["ENABLED"]:
if not OIDC_BROWSER_TEST_API_TOKEN_AUTH["ISSUER"]:
raise ImproperlyConfigured(
"API token authentication is enabled, but no issuer is configured. "
"Set OIDC_BROWSER_TEST_API_TOKEN_AUTH['ISSUER']."
)
if not is_valid_256_bit_key(OIDC_BROWSER_TEST_API_TOKEN_AUTH["JWT_SIGN_SECRET"]):
raise ImproperlyConfigured(
"JWT secret key for BrowserTestAwareJWTAuthentication must be 256-bits. "
"Set OIDC_BROWSER_TEST_API_TOKEN_AUTH['JWT_SIGN_SECRET']."
)


SITE_ID = 1

PARLER_LANGUAGES = {SITE_ID: ({"code": "fi"}, {"code": "sv"}, {"code": "en"})}
Expand Down
32 changes: 32 additions & 0 deletions kukkuu/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,40 @@
import secrets

import pytest

from kukkuu.service import get_hashid_service
from kukkuu.tests.utils.jwt_utils import generate_symmetric_test_jwt
from users.factories import UserFactory


@pytest.fixture
def hashids():
return get_hashid_service()


@pytest.fixture(autouse=True)
def oidc_browser_test_api_token_auth_settings(settings):
settings.OIDC_BROWSER_TEST_API_TOKEN_AUTH = {
"ENABLED": True,
"AUDIENCE": ["kukkuu-api-dev", "profile-api-test", "kukkuu-admin-ui-test"],
"API_SCOPE_PREFIX": "",
"REQUIRE_API_SCOPE_FOR_AUTHENTICATION": False,
"API_AUTHORIZATION_FIELD": "authorization.permissions.scopes",
"ISSUER": "https://kukkuu-ui.test.hel.ninja",
"JWT_SIGN_SECRET": secrets.token_bytes(32).hex(),
}


@pytest.fixture
def get_browser_test_bearer_token_for_user(oidc_browser_test_api_token_auth_settings):
"""Returns a test JWT token generator function.
The generator function returns a signed bearer token to authenticate through
the authentcation made for browser testing."""

default_user = UserFactory.build()

def generate_test_jwt_token(user=default_user):
return generate_symmetric_test_jwt(user)

return generate_test_jwt_token
Loading

0 comments on commit 718d92c

Please sign in to comment.