Skip to content

Commit

Permalink
Merge pull request #27 from pennlabs/add-identity-2
Browse files Browse the repository at this point in the history
B2B IPC
  • Loading branch information
joyliu-q authored Feb 5, 2023
2 parents 813c8f5 + 9e2d658 commit bcdfb36
Show file tree
Hide file tree
Showing 24 changed files with 756 additions and 342 deletions.
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Add `accounts` to `INSTALLED_APPS`
INSTALLED_APPS = (
...
'accounts.apps.AccountsConfig',
'identity.apps.IdentityConfig', # If you want to enable B2B IPC
...
)
```
Expand Down Expand Up @@ -117,10 +118,52 @@ class CustomBackend(LabsUserBackend):
user.save()
```

## B2B IPC

DLA also provides an interface for backend to backend IPC requests. With B2B IPC implemented, the backend of a product will—at startup time—request platform for a JWT to verify its identity. Each product will have an allow-list, and this will enable products to make requests to each other.

In order to limit a view to only be available to a B2B IPC request, you can use the included DRF permission:

```python
from identity.permissions import B2BPermission
class TestView(APIView):
permission_classes = [B2BPermission("urn:pennlabs:example")]
```

Make sure to define an URN to limit access. Valid URNs are either a specific product (ex. `urn:pennlabs:platform`) or a wildcard (ex. `urn:pennlabs:*`)

In order to make an IPC request, use the included helper function:

```python
from identity.identity import authenticated_b2b_request
result = authenticated_b2b_request('GET', 'http://url/path')
```

## Use in Production

DLA and Penn Labs' templates are set up so that no configuration is needed to run in development. However, in production a client ID and client secret need to be set. These values should be set in vault. Contact platform for both credentials and any questions you have.

## B2B IPC

DLA also provides an interface for backend to backend IPC requests. In order to limit a view to only be available to a B2B IPC request, you can use the included DRF permission:

```python
from identity.permissions import B2BPermission

class TestView(APIView):
permission_classes = [B2BPermission("urn:pennlabs:example")]
```

Make sure to define an URN to limit access. Valid URNs are either a specific product (ex. `urn:pennlabs:platform`) or a wildcard (ex. `urn:pennlabs:*`)

In order to make an IPC request, use the included helper function:

```python
from identity.identity import authenticated_b2b_request

result = authenticated_b2b_request('GET', 'http://url/path')
```

## Changelog

See [CHANGELOG.md](https://github.com/pennlabs/django-labs-accounts/blob/master/CHANGELOG.md)
Expand Down
5 changes: 5 additions & 0 deletions accounts/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from rest_framework import authentication, exceptions

from accounts.settings import accounts_settings
from identity.identity import get_validated_claims


User = get_user_model()
Expand All @@ -21,6 +22,7 @@ class PlatformAuthentication(authentication.BaseAuthentication):

keyword = "Bearer"

# DLA receives an incoming authentication request (from another product DLA) and processes it
def authenticate(self, request):
authorization = request.META.get("HTTP_AUTHORIZATION", "").split()
if not authorization or authorization[0] != self.keyword:
Expand All @@ -41,6 +43,9 @@ def authenticate(self, request):
data=body,
)
if platform_request.status_code != 200: # Access token is invalid
# Allow access to a validated Platform JWT
if get_validated_claims(token):
return (None, None)
raise exceptions.AuthenticationFailed("Invalid access token.")
json = platform_request.json()
user_props = json["user"]
Expand Down
8 changes: 5 additions & 3 deletions accounts/ipc.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from accounts.settings import accounts_settings


# IPC on behalf of a user for when a user in a product wants to use an
# authenticated route on another product.
def authenticated_request(
user,
method,
Expand Down Expand Up @@ -79,9 +81,9 @@ def _refresh_access_token(user):
"""
body = {
"grant_type": "refresh_token",
"client_id": accounts_settings.CLIENT_ID,
"client_secret": accounts_settings.CLIENT_SECRET,
"refresh_token": user.refreshtoken.token,
"client_id": accounts_settings.CLIENT_ID, # from Product
"client_secret": accounts_settings.CLIENT_SECRET, # from Product
"refresh_token": user.refreshtoken.token, # refresh token from user
}
try:
data = requests.post(
Expand Down
Empty file added identity/__init__.py
Empty file.
12 changes: 12 additions & 0 deletions identity/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from django.apps import AppConfig

from identity.identity import attest, get_platform_jwks


class IdentityConfig(AppConfig):
name = "identity"
verbose_name = "Penn Labs Service Identity"

def ready(self):
get_platform_jwks()
attest()
160 changes: 160 additions & 0 deletions identity/identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import json
import re
import time

import requests
from django.core.exceptions import ImproperlyConfigured
from jwcrypto import jwk, jwt

from accounts.settings import accounts_settings


JWKS_URL = f"{accounts_settings.PLATFORM_URL}/identity/jwks/"
ATTEST_URL = f"{accounts_settings.PLATFORM_URL}/identity/attest/"
REFRESH_URL = f"{accounts_settings.PLATFORM_URL}/identity/refresh/"


class IdentityContainer:
refresh_jwt: jwt.JWT = None
access_jwt: jwt.JWT = None
platform_jwks: jwk.JWKSet = None


container = IdentityContainer()


def get_platform_jwks():
"""
Download the JWKS from Platform to verify JWTs
"""

try:
response = requests.get(JWKS_URL)
# For some reason this method wants a raw string instead of a python dictionary
container.platform_jwks = jwk.JWKSet.from_json(response.text)
except Exception:
container.platform_jwks = None


def attest():
"""
Perform the initial authentication (attest) with Platform using the Client ID
and Secret from DOT
"""

response = requests.post(
ATTEST_URL, auth=(accounts_settings.CLIENT_ID, accounts_settings.CLIENT_SECRET)
)
if response.status_code == 200:
content = response.json()
container.access_jwt = jwt.JWT(
key=container.platform_jwks, jwt=content["access"]
)
container.refresh_jwt = jwt.JWT(
key=container.platform_jwks, jwt=content["refresh"]
)
return True
return False


def validate_urn(urn):
"""
Validate an urn to ensure it follows the specification we use in Penn Labs.
Use the format `urn:<organization>:<product slug or wildcard>`
Ex. `urn:pennlabs:platform` or `urn:pennlabs:*`.
"""
# Matches urn:<organization>:<product or wildcard>
pattern = re.compile(r"^urn:[a-z-]+:(?:[a-z-]+|\*)$")
if not pattern.match(urn):
raise ImproperlyConfigured(f"Invalid urn: '{urn}'")


def get_validated_claims(token):
"""
Validates JWT and returns the claims if validated, None otherwise.
"""
try:
validated_jwt = jwt.JWT(key=container.platform_jwks, jwt=token)
claims = json.loads(validated_jwt.claims)
return (
claims
if "use" in claims and claims["use"] == "access" and "sub" in claims
else None
)
except ValueError:
# Catches error if token has unrecognizable format
return None


def _refresh_if_outdated():
"""
Refresh the access jwt if it is expired.
"""
access_claims = json.loads(container.access_jwt.claims)
# only continue if our access jwt is expired (with a 30 second buffer)
if time.time() < access_claims["exp"] - 30:
return

auth_headers = {"Authorization": f"Bearer {container.refresh_jwt.serialize()}"}
response = requests.post(REFRESH_URL, headers=auth_headers)
if response.status_code == 200:
content = response.json()
container.access_jwt = jwt.JWT(
key=container.platform_jwks, jwt=content["access"]
)
else:
if not attest(): # If attest fails
raise Exception("Cannot authenticate with platform")


def authenticated_b2b_request(
method,
url,
params=None,
data=None,
headers={},
cookies=None,
files=None,
timeout=None,
allow_redirects=True,
proxies=None,
hooks=None,
stream=None,
verify=None,
cert=None,
json=None,
):
"""
Helper method to make an authenticated b2b request NOTE be ABSOLUTELY sure you
only make a request to Penn Labs products, otherwise you will expose credentials
and bad things will happen
"""

# Attempt refresh
_refresh_if_outdated()

# Update Headers
headers["Authorization"] = f"Bearer {container.access_jwt.serialize()}"

# Make the request
# We're only using a session to provide an easy wrapper to define the http method
# GET, POST, etc in the method call.
s = requests.Session()
return s.request(
method=method,
url=url,
params=params,
data=data,
headers=headers,
cookies=cookies,
files=files,
auth=None,
timeout=timeout,
allow_redirects=allow_redirects,
proxies=proxies,
hooks=hooks,
stream=stream,
verify=verify,
cert=cert,
json=json,
)
41 changes: 41 additions & 0 deletions identity/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from rest_framework import permissions

from identity.identity import get_validated_claims, validate_urn


def B2BPermission(urn):
"""
Create a B2BPermission that only grants access to a product with
the provided urn.
"""

class B2BPermissionInner(permissions.BasePermission):
"""
Grants permission if the current user is a superuser.
If authentication is successful `request.product` is set
to the urn of the product making the request.
"""

def has_permission(self, request, view):
self.urn = urn
# Get Authorization header
authorization = request.META.get("HTTP_AUTHORIZATION")
if authorization and " " in authorization:
auth_type, raw_jwt = authorization.split()
if auth_type == "Bearer":
try:
if claims := get_validated_claims(raw_jwt):
# Validate urn (wildcard prefix or exact match)
if (
self.urn.endswith("*")
and claims["sub"].startswith(self.urn[:-1])
) or claims["sub"] == self.urn:
# Expose product urn to view
request.product = claims["sub"]
return True
except Exception:
return False
return False

validate_urn(urn)
return B2BPermissionInner
Loading

0 comments on commit bcdfb36

Please sign in to comment.