Skip to content

Commit

Permalink
Add MAuthASGIMiddleware (#34)
Browse files Browse the repository at this point in the history
  • Loading branch information
ejinotti-mdsol authored Sep 8, 2022
1 parent a67435e commit 1ef9df2
Show file tree
Hide file tree
Showing 13 changed files with 793 additions and 316 deletions.
8 changes: 4 additions & 4 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ language: python
cache: pip

python:
- 3.6
- 3.7.13 # specify micro version to avoid having EnvCommandError
- 3.8
- 3.9
- 3.10

before_install:
- pip install poetry
- pip install poetry==1.1.15 # latest poetry 1.1.20 was having problems with travis
- pip install tox-travis

install: poetry install -v
Expand All @@ -25,12 +25,12 @@ stages:
jobs:
include:
- stage: lint
python: 3.8
python: 3.10
script:
- poetry run flake8 --version
- poetry run flake8
- stage: publish
python: 3.8
python: 3.10
script: skip
before_deploy:
- poetry config pypi-token.pypi $POETRY_PYPI_TOKEN_PYPI # this may be unnecessary
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 1.3.0
- Add `MAuthASGIMiddleware` for authenticating requests in ASGI frameworks like FastAPI.
- Remove Support for EOL Python 3.6

# 1.2.3
- Ignore `boto3` import error (`ModuleNotFoundError`).

Expand Down
26 changes: 8 additions & 18 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,14 @@ To setup your environment:
brew update
brew install pyenv
```
1. Install Pyenv versions for the Tox Suite
1. Install your favorite Python version (>= 3.8 please!)
```bash
pyenv install 3.5.8
pyenv install 3.6.10
pyenv install 3.7.7
pyenv install 3.8.2
pyenv install pypy3.6-7.3.1
pyenv install <YOUR_FAVORITE_VERSION>
```
1. Install Poetry
1. Install Poetry, see: https://python-poetry.org/docs/#installation
1. Install Dependencies
```bash
curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python
```
1. Install Tox
```bash
pip install tox
```
1. Setup the local project versions (one for each env in the `envlist`)
```bash
pyenv local 3.5.8 3.6.10 3.7.7 3.8.2 pypy3.6-7.1.1
poetry install -v
```


Expand All @@ -54,5 +43,6 @@ to init the submodule.

## Unit Tests

1. Make any changes, update the tests and then run tests with `tox`
1. Coverage report can be viewed using `open htmlcov/index.html`
1. Make any changes, update the tests and then run tests with `poetry run tox`.
1. Coverage report can be viewed using `open htmlcov/index.html`.
1. Or if you don't care about tox, just run `poetry run pytest` or `poetry run pytest <SOME_FILE>`.
44 changes: 35 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,22 +114,16 @@ app_uuid = authenticator.get_app_uuid()

#### Flask applications

You will need to create an application instance and initialize it with FlaskAuthenticator:
You will need to create an application instance and initialize it with `FlaskAuthenticator`.
To specify routes that need to be authenticated use the `requires_authentication` decorator.

```python
from flask import Flask
from mauth_client.flask_authenticator import FlaskAuthenticator
from mauth_client.flask_authenticator import FlaskAuthenticator, requires_authentication

app = Flask("Some Sample App")
authenticator = FlaskAuthenticator()
authenticator.init_app(app)
```

To specify routes that need to be authenticated use the `requires_authentication` decorator:

```python
from flask import Flask
from mauth_client.flask_authenticator import requires_authentication

@app.route("/some/private/route", methods=["GET"])
@requires_authentication
Expand All @@ -141,6 +135,38 @@ def app_status():
return "OK"
```

#### ASGI applications

To apply to an ASGI application you should use the `MAuthASGIMiddleware`. You
can make certain paths exempt from authentication by passing the `exempt`
option with a set of paths to exempt.

Here is an example for FastAPI. Note that requesting app's UUID and the
protocol version will be added to the ASGI `scope` for successfully
authenticated requests.

```python
from fastapi import FastAPI, Request
from mauth_client.constants import ENV_APP_UUID, ENV_PROTOCOL_VERSION
from mauth_client.middlewares import MAuthASGIMiddleware

app = FastAPI()
app.add_middleware(MAuthASGIMiddleware, exempt={"/app_status"})

@app.get("/")
async def root(request: Request):
return {
"msg": "authenticated",
"app_uuid": request.scope[ENV_APP_UUID],
"protocol_version": request.scope[ENV_PROTOCOL_VERSION],
}

@app.get("/app_status")
async def app_status():
return {
"msg": "this route is exempt from authentication",
}
```

## Contributing

Expand Down
4 changes: 4 additions & 0 deletions mauth_client/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@
MCC_AUTH = "MCC-Authentication"
MCC_TIME = "MCC-Time"
MWSV2_AUTH_PATTERN = re.compile(r"({}) ([^:]+):([^;]+){}".format(MWSV2_TOKEN, AUTH_HEADER_DELIMITER))

ENV_APP_UUID = "mauth.app_uuid"
ENV_AUTHENTIC = "mauth.authentic"
ENV_PROTOCOL_VERSION = "mauth.protocol_version"
1 change: 1 addition & 0 deletions mauth_client/middlewares/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .asgi import MAuthASGIMiddleware
115 changes: 115 additions & 0 deletions mauth_client/middlewares/asgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import json
import logging

from asgiref.typing import (
ASGI3Application,
ASGIReceiveCallable,
ASGIReceiveEvent,
ASGISendCallable,
Scope,
)
from typing import List, Tuple, Optional

from mauth_client.authenticator import LocalAuthenticator
from mauth_client.config import Config
from mauth_client.consts import (
ENV_APP_UUID,
ENV_AUTHENTIC,
ENV_PROTOCOL_VERSION,
)
from mauth_client.signable import RequestSignable
from mauth_client.signed import Signed
from mauth_client.utils import decode

logger = logging.getLogger("mauth_asgi")


class MAuthASGIMiddleware:
def __init__(self, app: ASGI3Application, exempt: Optional[set] = None) -> None:
self._validate_configs()
self.app = app
self.exempt = exempt.copy() if exempt else set()

async def __call__(
self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
) -> None:
path = scope["path"]

if scope["type"] != "http" or path in self.exempt:
return await self.app(scope, receive, send)

query_string = scope["query_string"]
url = f"{path}?{decode(query_string)}" if query_string else path
headers = {decode(k): decode(v) for k, v in scope["headers"]}

events, body = await self._get_body(receive)

signable = RequestSignable(
method=scope["method"],
url=url,
body=body,
)
signed = Signed.from_headers(headers)
authenticator = LocalAuthenticator(signable, signed, logger)
is_authentic, status, message = authenticator.is_authentic()

if is_authentic:
# asgi spec calls for passing a copy of the scope rather than mutating it
# note: deepcopy will blow up with infi recursion due to objects in some values
scope_copy = scope.copy()
scope_copy[ENV_APP_UUID] = signed.app_uuid
scope_copy[ENV_AUTHENTIC] = True
scope_copy[ENV_PROTOCOL_VERSION] = signed.protocol_version()
await self.app(scope_copy, self._fake_receive(events), send)
else:
await self._send_response(send, status, message)

def _validate_configs(self) -> None:
# Validate the client settings (APP_UUID, PRIVATE_KEY)
if not all([Config.APP_UUID, Config.PRIVATE_KEY]):
raise TypeError("MAuthASGIMiddleware requires APP_UUID and PRIVATE_KEY")
# Validate the mauth settings (MAUTH_BASE_URL, MAUTH_API_VERSION)
if not all([Config.MAUTH_URL, Config.MAUTH_API_VERSION]):
raise TypeError("MAuthASGIMiddleware requires MAUTH_URL and MAUTH_API_VERSION")

async def _get_body(
self, receive: ASGIReceiveCallable
) -> Tuple[List[ASGIReceiveEvent], bytes]:
body = b""
more_body = True
events = []

while more_body:
event = await receive()
body += event.get("body", b"")
more_body = event.get("more_body", False)
events.append(event)
return (events, body)

async def _send_response(self, send: ASGISendCallable, status: int, msg: str) -> None:
await send({
"type": "http.response.start",
"status": status,
"headers": [(b"content-type", b"application/json")],
})
body = {"errors": {"mauth": [msg]}}
await send({
"type": "http.response.body",
"body": json.dumps(body).encode("utf-8"),
})

def _fake_receive(self, events: List[ASGIReceiveEvent]) -> ASGIReceiveCallable:
"""
Create a fake, async receive function using an iterator of the events
we've already read. This will be passed to downstream middlewares/apps
instead of the usual receive fn, so that they can also "receive" the
body events.
"""
events_iter = iter(events)

async def _receive() -> ASGIReceiveEvent:
try:
return next(events_iter)
except StopIteration:
pass
return _receive
12 changes: 12 additions & 0 deletions mauth_client/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import base64
import cchardet
from hashlib import sha512


Expand All @@ -20,3 +21,14 @@ def hexdigest(val):

def base64_encode(signature):
return base64.b64encode(signature).decode("US-ASCII").replace("\n", "")


def decode(byte_string: bytes) -> str:
"""
Attempt to decode a byte string with utf and fallback to cchardet.
"""
try:
return byte_string.decode("utf-8")
except UnicodeDecodeError:
encoding = cchardet.detect(byte_string)["encoding"]
return byte_string.decode(encoding)
Loading

0 comments on commit 1ef9df2

Please sign in to comment.