kopia lustrzana https://github.com/Yakifo/amqtt
add documentation, expand functionality to handle case including test cases
rodzic
4a622f7e8c
commit
893aec2d4a
|
@ -44,8 +44,11 @@ class ACLError(Exception):
|
||||||
HTTP_2xx_MIN = 200
|
HTTP_2xx_MIN = 200
|
||||||
HTTP_2xx_MAX = 300
|
HTTP_2xx_MAX = 300
|
||||||
|
|
||||||
|
HTTP_4xx_MIN = 400
|
||||||
|
HTTP_4xx_MAX = 500
|
||||||
|
|
||||||
class HttpAuthACL(BaseAuthPlugin, BaseTopicPlugin):
|
|
||||||
|
class HttpAuthACLPlugin(BaseAuthPlugin, BaseTopicPlugin):
|
||||||
|
|
||||||
def __init__(self, context: BrokerContext) -> None:
|
def __init__(self, context: BrokerContext) -> None:
|
||||||
super().__init__(context)
|
super().__init__(context)
|
||||||
|
@ -66,8 +69,11 @@ class HttpAuthACL(BaseAuthPlugin, BaseTopicPlugin):
|
||||||
def _is_2xx(r: ClientResponse) -> bool:
|
def _is_2xx(r: ClientResponse) -> bool:
|
||||||
return HTTP_2xx_MIN <= r.status < HTTP_2xx_MAX
|
return HTTP_2xx_MIN <= r.status < HTTP_2xx_MAX
|
||||||
|
|
||||||
async def _send_request(self, url: str, payload: dict[str, Any]) -> bool:
|
@staticmethod
|
||||||
|
def _is_4xx(r: ClientResponse) -> bool:
|
||||||
|
return HTTP_4xx_MIN <= r.status < HTTP_4xx_MAX
|
||||||
|
|
||||||
|
def _get_params(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
match self.config.params_mode:
|
match self.config.params_mode:
|
||||||
case ParamsMode.FORM:
|
case ParamsMode.FORM:
|
||||||
match self.config.request_method:
|
match self.config.request_method:
|
||||||
|
@ -78,20 +84,32 @@ class HttpAuthACL(BaseAuthPlugin, BaseTopicPlugin):
|
||||||
kwargs = {"data": d}
|
kwargs = {"data": d}
|
||||||
case _: # JSON
|
case _: # JSON
|
||||||
kwargs = { "json": payload}
|
kwargs = { "json": payload}
|
||||||
|
return kwargs
|
||||||
|
|
||||||
async with self.method(url, **kwargs) as r: # type: ignore[arg-type]
|
async def _send_request(self, url: str, payload: dict[str, Any]) -> bool|None: # pylint: disable=R0911
|
||||||
|
|
||||||
|
kwargs = self._get_params(payload)
|
||||||
|
|
||||||
|
async with self.method(url, **kwargs) as r:
|
||||||
logger.debug(f"http request returned {r.status}")
|
logger.debug(f"http request returned {r.status}")
|
||||||
if not self._is_2xx(r):
|
|
||||||
return False
|
|
||||||
|
|
||||||
match self.config.response_mode:
|
match self.config.response_mode:
|
||||||
case ResponseMode.TEXT:
|
case ResponseMode.TEXT:
|
||||||
return (await r.text()).lower() == "ok"
|
return self._is_2xx(r) and (await r.text()).lower() == "ok"
|
||||||
case ResponseMode.STATUS:
|
case ResponseMode.STATUS:
|
||||||
return self._is_2xx(r)
|
if self._is_2xx(r):
|
||||||
|
return True
|
||||||
|
if self._is_4xx(r):
|
||||||
|
return False
|
||||||
|
# any other code
|
||||||
|
return None
|
||||||
case _:
|
case _:
|
||||||
|
if not self._is_2xx(r):
|
||||||
|
return False
|
||||||
data = await r.json()
|
data = await r.json()
|
||||||
for ok in ("OK", "Ok", "ok"):
|
for ok in ("OK", "Ok", "ok"):
|
||||||
|
if ok in data and data[ok] is None:
|
||||||
|
return None
|
||||||
if ok in data and isinstance(data[ok], bool):
|
if ok in data and isinstance(data[ok], bool):
|
||||||
return bool(data[ok])
|
return bool(data[ok])
|
||||||
return False
|
return False
|
||||||
|
|
|
@ -3,3 +3,48 @@
|
||||||
Plugins that are not part of the core functionality of the aMQTT broker or client, often requiring additional dependencies.
|
Plugins that are not part of the core functionality of the aMQTT broker or client, often requiring additional dependencies.
|
||||||
|
|
||||||
|
|
||||||
|
## Authentication & Topic Access via external HTTP server
|
||||||
|
|
||||||
|
`amqtt.contrib.http.HttpAuthACLPlugin`
|
||||||
|
|
||||||
|
If clients accessing the broker are managed by another application, implement API endpoints
|
||||||
|
that allows the broker to check if a client is authenticated and what topics that client
|
||||||
|
is authorized to access.
|
||||||
|
|
||||||
|
**Configuration**
|
||||||
|
|
||||||
|
- `host` *(str) hostname of the server for the auth & acl check
|
||||||
|
- `port` *(int) port of the server for the auth & acl check
|
||||||
|
- `user_uri` *(str) uri of the topic check (e.g. '/user')
|
||||||
|
- `acl_uri` *(str) uri of the topic check (e.g. '/acl')
|
||||||
|
- `request_method` *(RequestMethod) send the request as a GET, POST or PUT
|
||||||
|
- `params_mode` *(ParamsMode) send the request with json or form data
|
||||||
|
- `response_mode` *(ResponseMode) expected response from the auth/acl server. STATUS (code), JSON, or TEXT.
|
||||||
|
- `user_agent` *(str) the 'User-Agent' header sent along with the request
|
||||||
|
|
||||||
|
Each endpoint (uri) will receive the information needed to determine authentication and authorization (in either
|
||||||
|
json or form data format, based on the `params_mode`)
|
||||||
|
|
||||||
|
For user authentication (`user_uri`), the http server will receive in json or form format the following:
|
||||||
|
- username *(str)*
|
||||||
|
- password *(str)*
|
||||||
|
- client_id *(str)*
|
||||||
|
|
||||||
|
For superuser validation (`superuser_uri`), the http server will receive in json or form format the following:
|
||||||
|
- username *(str)*
|
||||||
|
|
||||||
|
For acl check (`acl_uri`), the http server will receive in json or form format the following:
|
||||||
|
- username *(str)*
|
||||||
|
- client_id *(str)*
|
||||||
|
- topic *(str)*
|
||||||
|
- acc *(int)* client can receive (1), can publish(2), can receive & publish (3) and can subscribe (4)
|
||||||
|
|
||||||
|
|
||||||
|
The HTTP endpoints can respond in three different ways, depending on `response_mode`:
|
||||||
|
|
||||||
|
1. STATUS - allowing access should respond with a 2xx status code. rejection is 4xx.
|
||||||
|
if a 5xx is received, the plugin will not participate in the filtering operation and will defer to another topic filtering plugin to determine access
|
||||||
|
2. JSON - response should be `{'ok':true|false|null, 'error':'optional reason for false or null response'}`.
|
||||||
|
`true` allows access, `false` denies access and `null` the plugin will not participate in the filtering operation
|
||||||
|
3. TEXT - `ok` allows access, any other message denies access. non-participation not supported with this mode.
|
||||||
|
|
||||||
|
|
|
@ -7,51 +7,59 @@ from aiohttp.web import Response
|
||||||
|
|
||||||
from amqtt.broker import BrokerContext, Broker
|
from amqtt.broker import BrokerContext, Broker
|
||||||
from amqtt.contexts import Action
|
from amqtt.contexts import Action
|
||||||
from amqtt.contrib.http import HttpAuthACL, ParamsMode, ResponseMode, RequestMethod
|
from amqtt.contrib.http import HttpAuthACLPlugin, ParamsMode, ResponseMode, RequestMethod
|
||||||
from amqtt.session import Session
|
from amqtt.session import Session
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def determine_auth_response_mode(d) -> Response:
|
def determine_auth_response(d) -> Response:
|
||||||
|
# check that auth response contains the correct params
|
||||||
assert 'username' in d
|
assert 'username' in d
|
||||||
assert 'password' in d
|
assert 'password' in d
|
||||||
assert 'client_id' in d
|
assert 'client_id' in d
|
||||||
|
# use the username to determine response kind
|
||||||
if d['username'] == 'json':
|
if d['username'] == 'json':
|
||||||
|
# special case, i_am_null respond with None
|
||||||
|
if d['password'] == 'i_am_null':
|
||||||
|
return web.json_response({'Ok': None})
|
||||||
|
# otherwise, respond depending on if username and client_id match
|
||||||
return web.json_response({'Ok': d['username'] == d['password']})
|
return web.json_response({'Ok': d['username'] == d['password']})
|
||||||
elif d['username'] == 'status':
|
elif d['username'] == 'status':
|
||||||
|
if d['password'] == 'i_am_null':
|
||||||
|
return web.Response(status=500)
|
||||||
return web.Response(status=200) if d['username'] == d['password'] else web.Response(status=400)
|
return web.Response(status=200) if d['username'] == d['password'] else web.Response(status=400)
|
||||||
else: # text
|
else: # text
|
||||||
return web.Response(text='ok' if d['username'] == d['password'] else 'error')
|
return web.Response(text='ok' if d['username'] == d['password'] else 'error')
|
||||||
|
|
||||||
|
# aiohttp doesn't have a common `dispatch` method like django; therefore, need to take the non-DRY approach...
|
||||||
class JsonAuthView(web.View):
|
class JsonAuthView(web.View):
|
||||||
|
|
||||||
async def get(self) -> Response:
|
async def get(self) -> Response:
|
||||||
d = await self.request.json()
|
d = await self.request.json()
|
||||||
return determine_auth_response_mode(d)
|
return determine_auth_response(d)
|
||||||
|
|
||||||
async def post(self) -> Response:
|
async def post(self) -> Response:
|
||||||
d = dict(await self.request.json())
|
d = dict(await self.request.json())
|
||||||
return determine_auth_response_mode(d)
|
return determine_auth_response(d)
|
||||||
|
|
||||||
async def put(self) -> Response:
|
async def put(self) -> Response:
|
||||||
d = dict(await self.request.json())
|
d = dict(await self.request.json())
|
||||||
return determine_auth_response_mode(d)
|
return determine_auth_response(d)
|
||||||
|
|
||||||
class FormAuthView(web.View):
|
class FormAuthView(web.View):
|
||||||
|
|
||||||
async def get(self) -> Response:
|
async def get(self) -> Response:
|
||||||
d = self.request.query
|
d = self.request.query
|
||||||
return determine_auth_response_mode(d)
|
return determine_auth_response(d)
|
||||||
|
|
||||||
async def post(self) -> Response:
|
async def post(self) -> Response:
|
||||||
d = dict(await self.request.post())
|
d = dict(await self.request.post())
|
||||||
return determine_auth_response_mode(d)
|
return determine_auth_response(d)
|
||||||
|
|
||||||
async def put(self) -> Response:
|
async def put(self) -> Response:
|
||||||
d = dict(await self.request.post())
|
d = dict(await self.request.post())
|
||||||
return determine_auth_response_mode(d)
|
return determine_auth_response(d)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
@ -90,15 +98,24 @@ def generate_use_cases(root_url):
|
||||||
for params in ParamsMode:
|
for params in ParamsMode:
|
||||||
for response in ResponseMode:
|
for response in ResponseMode:
|
||||||
url = f'/{root_url}/json' if params == ParamsMode.JSON else f'/{root_url}/form'
|
url = f'/{root_url}/json' if params == ParamsMode.JSON else f'/{root_url}/form'
|
||||||
for is_authenticated in [True, False]:
|
for is_authenticated in [True, False, None]:
|
||||||
prefix = '' if is_authenticated else 'not'
|
if is_authenticated is None:
|
||||||
case = (url, request, params, response, response.value, f"{prefix}{response.value}", is_authenticated)
|
pwd = 'i_am_null'
|
||||||
|
elif is_authenticated:
|
||||||
|
pwd = f'{response.value}'
|
||||||
|
else:
|
||||||
|
pwd = f'not{response.value}'
|
||||||
|
|
||||||
|
if response == ResponseMode.TEXT and is_authenticated is None:
|
||||||
|
is_authenticated = False
|
||||||
|
|
||||||
|
case = (url, request, params, response, response.value, f"{pwd}", is_authenticated)
|
||||||
cases.append(case)
|
cases.append(case)
|
||||||
return cases
|
return cases
|
||||||
|
|
||||||
def test_generated_use_cases():
|
def test_generated_use_cases():
|
||||||
cases = generate_use_cases('user')
|
cases = generate_use_cases('user')
|
||||||
assert len(cases) == 36
|
assert len(cases) == 54
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("url,request_method,params_mode,response_mode,username,password,is_authenticated",
|
@pytest.mark.parametrize("url,request_method,params_mode,response_mode,username,password,is_authenticated",
|
||||||
|
@ -109,7 +126,7 @@ async def test_request_auth_response(empty_broker, http_auth_server, url,
|
||||||
username, password, is_authenticated):
|
username, password, is_authenticated):
|
||||||
|
|
||||||
context = BrokerContext(broker=empty_broker)
|
context = BrokerContext(broker=empty_broker)
|
||||||
context.config = HttpAuthACL.Config(
|
context.config = HttpAuthACLPlugin.Config(
|
||||||
host="127.0.0.1",
|
host="127.0.0.1",
|
||||||
port=8080,
|
port=8080,
|
||||||
user_uri=url,
|
user_uri=url,
|
||||||
|
@ -118,7 +135,7 @@ async def test_request_auth_response(empty_broker, http_auth_server, url,
|
||||||
params_mode=params_mode,
|
params_mode=params_mode,
|
||||||
response_mode=response_mode,
|
response_mode=response_mode,
|
||||||
)
|
)
|
||||||
http_acl = HttpAuthACL(context)
|
http_acl = HttpAuthACLPlugin(context)
|
||||||
|
|
||||||
session = Session()
|
session = Session()
|
||||||
session.client_id = "my_client_id"
|
session.client_id = "my_client_id"
|
||||||
|
@ -130,13 +147,22 @@ async def test_request_auth_response(empty_broker, http_auth_server, url,
|
||||||
|
|
||||||
|
|
||||||
def determine_acl_response(d) -> Response:
|
def determine_acl_response(d) -> Response:
|
||||||
|
# make sure the params have the right categories
|
||||||
assert 'username' in d
|
assert 'username' in d
|
||||||
assert 'client_id' in d
|
assert 'client_id' in d
|
||||||
assert 'topic' in d
|
assert 'topic' in d
|
||||||
assert 'acc' in d
|
assert 'acc' in d
|
||||||
|
assert 1 <= int(d['acc']) <= 4
|
||||||
|
# use the username to determine response kind
|
||||||
if d['username'] == 'json':
|
if d['username'] == 'json':
|
||||||
|
# special case, i_am_null respond with None
|
||||||
|
if d['client_id'] == 'i_am_null':
|
||||||
|
return web.json_response({'Ok': None})
|
||||||
|
# otherwise, respond depending on if username and client_id match
|
||||||
return web.json_response({'Ok': d['username'] == d['client_id']})
|
return web.json_response({'Ok': d['username'] == d['client_id']})
|
||||||
elif d['username'] == 'status':
|
elif d['username'] == 'status':
|
||||||
|
if d['client_id'] == 'i_am_null':
|
||||||
|
return web.Response(status=500)
|
||||||
return web.Response(status=200) if d['username'] == d['client_id'] else web.Response(status=400)
|
return web.Response(status=200) if d['username'] == d['client_id'] else web.Response(status=400)
|
||||||
else: # text
|
else: # text
|
||||||
return web.Response(text='ok' if d['username'] == d['client_id'] else 'error')
|
return web.Response(text='ok' if d['username'] == d['client_id'] else 'error')
|
||||||
|
@ -202,7 +228,7 @@ async def test_request_acl_response(empty_broker, http_acl_server, url,
|
||||||
# response_mode = ResponseMode.JSON
|
# response_mode = ResponseMode.JSON
|
||||||
|
|
||||||
context = BrokerContext(broker=empty_broker)
|
context = BrokerContext(broker=empty_broker)
|
||||||
context.config = HttpAuthACL.Config(
|
context.config = HttpAuthACLPlugin.Config(
|
||||||
host="127.0.0.1",
|
host="127.0.0.1",
|
||||||
port=8080,
|
port=8080,
|
||||||
user_uri='/user',
|
user_uri='/user',
|
||||||
|
@ -211,7 +237,7 @@ async def test_request_acl_response(empty_broker, http_acl_server, url,
|
||||||
params_mode=params_mode,
|
params_mode=params_mode,
|
||||||
response_mode=response_mode,
|
response_mode=response_mode,
|
||||||
)
|
)
|
||||||
http_acl = HttpAuthACL(context)
|
http_acl = HttpAuthACLPlugin(context)
|
||||||
|
|
||||||
s = Session()
|
s = Session()
|
||||||
s.username = username
|
s.username = username
|
||||||
|
|
Ładowanie…
Reference in New Issue