Skip to content

Commit 7801215

Browse files
Merge pull request #35 from mdsol/develop
Develop => Master for 1.3.0
2 parents c72a26a + 1ef9df2 commit 7801215

File tree

13 files changed

+793
-316
lines changed

13 files changed

+793
-316
lines changed

.travis.yml

+4-4
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ language: python
33
cache: pip
44

55
python:
6-
- 3.6
76
- 3.7.13 # specify micro version to avoid having EnvCommandError
87
- 3.8
98
- 3.9
9+
- 3.10
1010

1111
before_install:
12-
- pip install poetry
12+
- pip install poetry==1.1.15 # latest poetry 1.1.20 was having problems with travis
1313
- pip install tox-travis
1414

1515
install: poetry install -v
@@ -25,12 +25,12 @@ stages:
2525
jobs:
2626
include:
2727
- stage: lint
28-
python: 3.8
28+
python: 3.10
2929
script:
3030
- poetry run flake8 --version
3131
- poetry run flake8
3232
- stage: publish
33-
python: 3.8
33+
python: 3.10
3434
script: skip
3535
before_deploy:
3636
- poetry config pypi-token.pypi $POETRY_PYPI_TOKEN_PYPI # this may be unnecessary

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# 1.3.0
2+
- Add `MAuthASGIMiddleware` for authenticating requests in ASGI frameworks like FastAPI.
3+
- Remove Support for EOL Python 3.6
4+
15
# 1.2.3
26
- Ignore `boto3` import error (`ModuleNotFoundError`).
37

CONTRIBUTING.md

+8-18
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,14 @@ To setup your environment:
1414
brew update
1515
brew install pyenv
1616
```
17-
1. Install Pyenv versions for the Tox Suite
17+
1. Install your favorite Python version (>= 3.8 please!)
1818
```bash
19-
pyenv install 3.5.8
20-
pyenv install 3.6.10
21-
pyenv install 3.7.7
22-
pyenv install 3.8.2
23-
pyenv install pypy3.6-7.3.1
19+
pyenv install <YOUR_FAVORITE_VERSION>
2420
```
25-
1. Install Poetry
21+
1. Install Poetry, see: https://python-poetry.org/docs/#installation
22+
1. Install Dependencies
2623
```bash
27-
curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python
28-
```
29-
1. Install Tox
30-
```bash
31-
pip install tox
32-
```
33-
1. Setup the local project versions (one for each env in the `envlist`)
34-
```bash
35-
pyenv local 3.5.8 3.6.10 3.7.7 3.8.2 pypy3.6-7.1.1
24+
poetry install -v
3625
```
3726

3827

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

5544
## Unit Tests
5645

57-
1. Make any changes, update the tests and then run tests with `tox`
58-
1. Coverage report can be viewed using `open htmlcov/index.html`
46+
1. Make any changes, update the tests and then run tests with `poetry run tox`.
47+
1. Coverage report can be viewed using `open htmlcov/index.html`.
48+
1. Or if you don't care about tox, just run `poetry run pytest` or `poetry run pytest <SOME_FILE>`.

README.md

+35-9
Original file line numberDiff line numberDiff line change
@@ -114,22 +114,16 @@ app_uuid = authenticator.get_app_uuid()
114114

115115
#### Flask applications
116116

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

119120
```python
120121
from flask import Flask
121-
from mauth_client.flask_authenticator import FlaskAuthenticator
122+
from mauth_client.flask_authenticator import FlaskAuthenticator, requires_authentication
122123

123124
app = Flask("Some Sample App")
124125
authenticator = FlaskAuthenticator()
125126
authenticator.init_app(app)
126-
```
127-
128-
To specify routes that need to be authenticated use the `requires_authentication` decorator:
129-
130-
```python
131-
from flask import Flask
132-
from mauth_client.flask_authenticator import requires_authentication
133127

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

138+
#### ASGI applications
139+
140+
To apply to an ASGI application you should use the `MAuthASGIMiddleware`. You
141+
can make certain paths exempt from authentication by passing the `exempt`
142+
option with a set of paths to exempt.
143+
144+
Here is an example for FastAPI. Note that requesting app's UUID and the
145+
protocol version will be added to the ASGI `scope` for successfully
146+
authenticated requests.
147+
148+
```python
149+
from fastapi import FastAPI, Request
150+
from mauth_client.constants import ENV_APP_UUID, ENV_PROTOCOL_VERSION
151+
from mauth_client.middlewares import MAuthASGIMiddleware
152+
153+
app = FastAPI()
154+
app.add_middleware(MAuthASGIMiddleware, exempt={"/app_status"})
155+
156+
@app.get("/")
157+
async def root(request: Request):
158+
return {
159+
"msg": "authenticated",
160+
"app_uuid": request.scope[ENV_APP_UUID],
161+
"protocol_version": request.scope[ENV_PROTOCOL_VERSION],
162+
}
163+
164+
@app.get("/app_status")
165+
async def app_status():
166+
return {
167+
"msg": "this route is exempt from authentication",
168+
}
169+
```
144170

145171
## Contributing
146172

mauth_client/consts.py

+4
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@
1111
MCC_AUTH = "MCC-Authentication"
1212
MCC_TIME = "MCC-Time"
1313
MWSV2_AUTH_PATTERN = re.compile(r"({}) ([^:]+):([^;]+){}".format(MWSV2_TOKEN, AUTH_HEADER_DELIMITER))
14+
15+
ENV_APP_UUID = "mauth.app_uuid"
16+
ENV_AUTHENTIC = "mauth.authentic"
17+
ENV_PROTOCOL_VERSION = "mauth.protocol_version"

mauth_client/middlewares/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .asgi import MAuthASGIMiddleware

mauth_client/middlewares/asgi.py

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import json
2+
import logging
3+
4+
from asgiref.typing import (
5+
ASGI3Application,
6+
ASGIReceiveCallable,
7+
ASGIReceiveEvent,
8+
ASGISendCallable,
9+
Scope,
10+
)
11+
from typing import List, Tuple, Optional
12+
13+
from mauth_client.authenticator import LocalAuthenticator
14+
from mauth_client.config import Config
15+
from mauth_client.consts import (
16+
ENV_APP_UUID,
17+
ENV_AUTHENTIC,
18+
ENV_PROTOCOL_VERSION,
19+
)
20+
from mauth_client.signable import RequestSignable
21+
from mauth_client.signed import Signed
22+
from mauth_client.utils import decode
23+
24+
logger = logging.getLogger("mauth_asgi")
25+
26+
27+
class MAuthASGIMiddleware:
28+
def __init__(self, app: ASGI3Application, exempt: Optional[set] = None) -> None:
29+
self._validate_configs()
30+
self.app = app
31+
self.exempt = exempt.copy() if exempt else set()
32+
33+
async def __call__(
34+
self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
35+
) -> None:
36+
path = scope["path"]
37+
38+
if scope["type"] != "http" or path in self.exempt:
39+
return await self.app(scope, receive, send)
40+
41+
query_string = scope["query_string"]
42+
url = f"{path}?{decode(query_string)}" if query_string else path
43+
headers = {decode(k): decode(v) for k, v in scope["headers"]}
44+
45+
events, body = await self._get_body(receive)
46+
47+
signable = RequestSignable(
48+
method=scope["method"],
49+
url=url,
50+
body=body,
51+
)
52+
signed = Signed.from_headers(headers)
53+
authenticator = LocalAuthenticator(signable, signed, logger)
54+
is_authentic, status, message = authenticator.is_authentic()
55+
56+
if is_authentic:
57+
# asgi spec calls for passing a copy of the scope rather than mutating it
58+
# note: deepcopy will blow up with infi recursion due to objects in some values
59+
scope_copy = scope.copy()
60+
scope_copy[ENV_APP_UUID] = signed.app_uuid
61+
scope_copy[ENV_AUTHENTIC] = True
62+
scope_copy[ENV_PROTOCOL_VERSION] = signed.protocol_version()
63+
await self.app(scope_copy, self._fake_receive(events), send)
64+
else:
65+
await self._send_response(send, status, message)
66+
67+
def _validate_configs(self) -> None:
68+
# Validate the client settings (APP_UUID, PRIVATE_KEY)
69+
if not all([Config.APP_UUID, Config.PRIVATE_KEY]):
70+
raise TypeError("MAuthASGIMiddleware requires APP_UUID and PRIVATE_KEY")
71+
# Validate the mauth settings (MAUTH_BASE_URL, MAUTH_API_VERSION)
72+
if not all([Config.MAUTH_URL, Config.MAUTH_API_VERSION]):
73+
raise TypeError("MAuthASGIMiddleware requires MAUTH_URL and MAUTH_API_VERSION")
74+
75+
async def _get_body(
76+
self, receive: ASGIReceiveCallable
77+
) -> Tuple[List[ASGIReceiveEvent], bytes]:
78+
body = b""
79+
more_body = True
80+
events = []
81+
82+
while more_body:
83+
event = await receive()
84+
body += event.get("body", b"")
85+
more_body = event.get("more_body", False)
86+
events.append(event)
87+
return (events, body)
88+
89+
async def _send_response(self, send: ASGISendCallable, status: int, msg: str) -> None:
90+
await send({
91+
"type": "http.response.start",
92+
"status": status,
93+
"headers": [(b"content-type", b"application/json")],
94+
})
95+
body = {"errors": {"mauth": [msg]}}
96+
await send({
97+
"type": "http.response.body",
98+
"body": json.dumps(body).encode("utf-8"),
99+
})
100+
101+
def _fake_receive(self, events: List[ASGIReceiveEvent]) -> ASGIReceiveCallable:
102+
"""
103+
Create a fake, async receive function using an iterator of the events
104+
we've already read. This will be passed to downstream middlewares/apps
105+
instead of the usual receive fn, so that they can also "receive" the
106+
body events.
107+
"""
108+
events_iter = iter(events)
109+
110+
async def _receive() -> ASGIReceiveEvent:
111+
try:
112+
return next(events_iter)
113+
except StopIteration:
114+
pass
115+
return _receive

mauth_client/utils.py

+12
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import base64
2+
import cchardet
23
from hashlib import sha512
34

45

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

2122
def base64_encode(signature):
2223
return base64.b64encode(signature).decode("US-ASCII").replace("\n", "")
24+
25+
26+
def decode(byte_string: bytes) -> str:
27+
"""
28+
Attempt to decode a byte string with utf and fallback to cchardet.
29+
"""
30+
try:
31+
return byte_string.decode("utf-8")
32+
except UnicodeDecodeError:
33+
encoding = cchardet.detect(byte_string)["encoding"]
34+
return byte_string.decode(encoding)

0 commit comments

Comments
 (0)