Compare commits
12 Commits
dc29abea84
...
f6d08e8309
Author | SHA1 | Date |
---|---|---|
Maxime Alves LIRMM | f6d08e8309 | |
Maxime Alves LIRMM | 262de901a8 | |
Maxime Alves LIRMM | e5c25ede1f | |
Maxime Alves LIRMM | b4c37ea999 | |
Maxime Alves LIRMM | 5a7e51ae94 | |
Maxime Alves LIRMM | 69129fd7af | |
Maxime Alves LIRMM | a3fc6dc830 | |
Maxime Alves LIRMM@home | 064127dc16 | |
Maxime Alves LIRMM@home | c2eb95533c | |
Maxime Alves LIRMM | bbb027cd0d | |
Maxime Alves LIRMM | c9ecc1f8d2 | |
Maxime Alves LIRMM | d1a8351997 |
10
CHANGELOG.md
10
CHANGELOG.md
|
@ -1,5 +1,15 @@
|
|||
# HalfAPI
|
||||
|
||||
## 0.6.25
|
||||
|
||||
- Deletes the "Authorization" cookie on authentication error
|
||||
- Do not raise an exception on signature expiration, use "Nobody" user instead
|
||||
|
||||
## 0.6.24
|
||||
|
||||
- Uses the "Authorization" cookie to read authentication token additionnaly to the "Authorization" header
|
||||
- CLI : allows to run a single domain using the "halfapi domain --run domain_name" command
|
||||
|
||||
## 0.6.23
|
||||
|
||||
Dependency update version
|
||||
|
|
42
README.md
42
README.md
|
@ -23,17 +23,15 @@ to reference [HalfORM](https://github.com/collorg/halfORM), a project written by
|
|||
|
||||
Configure HalfAPI in the file : .halfapi/config .
|
||||
|
||||
It's an **ini** file that contains at least two sections, project and domains.
|
||||
It's a **toml** file that contains at least two sections, project and domains.
|
||||
|
||||
https://toml.io/en/
|
||||
|
||||
|
||||
### Project
|
||||
|
||||
The main configuration options without which HalfAPI cannot be run.
|
||||
|
||||
**name** : Project's name
|
||||
|
||||
**halfapi_version** : The HalfAPI version on which you work
|
||||
|
||||
**secret** : The file containing the secret to decode the user's tokens.
|
||||
|
||||
**port** : The port for the test server.
|
||||
|
@ -43,12 +41,28 @@ The main configuration options without which HalfAPI cannot be run.
|
|||
|
||||
### Domains
|
||||
|
||||
The name of the options should be the name of the domains' module, the value is the
|
||||
submodule which contains the routers.
|
||||
Specify the domains configurations in the following form :
|
||||
|
||||
Example :
|
||||
```
|
||||
[domains.DOMAIN_NAME]
|
||||
name = "DOMAIN_NAME"
|
||||
enabled = true
|
||||
prefix = "/prefix"
|
||||
module = "domain_name.path.to.api.root"
|
||||
port = 1002
|
||||
```
|
||||
|
||||
dummy_domain = .routers
|
||||
Specific configuration can be done under the "config" section :
|
||||
|
||||
```
|
||||
[domains.DOMAIN_NAME.config]
|
||||
boolean_option = false
|
||||
string_value = "String"
|
||||
answer = 42
|
||||
listylist = ["hello", "world"]
|
||||
```
|
||||
|
||||
And can be accessed through the app's "config" dictionnary.
|
||||
|
||||
|
||||
## Usage
|
||||
|
@ -62,9 +76,17 @@ Run the project by using the `halfapi run` command.
|
|||
You can try the dummy_domain with the following command.
|
||||
|
||||
```
|
||||
python -m halfapi routes --export --noheader dummy_domain.routers | python -m halfapi run -
|
||||
PYTHONPATH=$PWD/tests python -m halfapi domain dummy_domain
|
||||
```
|
||||
|
||||
### CLI documentation
|
||||
|
||||
Use the CLI help.
|
||||
|
||||
```
|
||||
python -m halfapi --help
|
||||
python -m halfapi domain --help
|
||||
```
|
||||
|
||||
## API Testing
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
#!/usr/bin/env python3
|
||||
__version__ = '0.6.23'
|
||||
__version__ = '0.6.25'
|
||||
|
||||
def version():
|
||||
return f'HalfAPI version:{__version__}'
|
||||
|
|
|
@ -12,6 +12,7 @@ import json
|
|||
|
||||
import click
|
||||
import orjson
|
||||
import uvicorn
|
||||
|
||||
|
||||
from .cli import cli
|
||||
|
@ -118,14 +119,16 @@ def list_api_routes():
|
|||
# list_routes(domain, m_dom)
|
||||
|
||||
|
||||
@click.option('--read',default=True, is_flag=True)
|
||||
@click.option('--dry-run',default=False, is_flag=True)
|
||||
@click.option('--run',default=False, is_flag=True)
|
||||
@click.option('--read',default=False, is_flag=True)
|
||||
@click.option('--create',default=False, is_flag=True)
|
||||
@click.option('--update',default=False, is_flag=True)
|
||||
@click.option('--delete',default=False, is_flag=True)
|
||||
@click.argument('config_file', type=click.File(mode='rb'), required=False)
|
||||
@click.argument('domain',default=None, required=False)
|
||||
@cli.command()
|
||||
def domain(domain, config_file, delete, update, create, read): #, domains, read, create, update, delete):
|
||||
def domain(domain, config_file, delete, update, create, read, run, dry_run): #, domains, read, create, update, delete):
|
||||
"""
|
||||
The "halfapi domain" command
|
||||
|
||||
|
@ -142,11 +145,13 @@ def domain(domain, config_file, delete, update, create, read): #, domains, read
|
|||
# TODO: Connect to the create_domain function
|
||||
raise NotImplementedError
|
||||
raise Exception('Missing domain name')
|
||||
if update:
|
||||
if create:
|
||||
raise NotImplementedError
|
||||
if delete:
|
||||
elif update:
|
||||
raise NotImplementedError
|
||||
if read:
|
||||
elif delete:
|
||||
raise NotImplementedError
|
||||
elif read:
|
||||
from ..conf import CONFIG
|
||||
from ..halfapi import HalfAPI
|
||||
|
||||
|
@ -162,4 +167,27 @@ def domain(domain, config_file, delete, update, create, read): #, domains, read
|
|||
default=ORJSONResponse.default_cast)
|
||||
)
|
||||
|
||||
else:
|
||||
from ..conf import CONFIG
|
||||
if 'domain' not in CONFIG:
|
||||
CONFIG['domain'] = {}
|
||||
|
||||
if domain not in CONFIG['domain']:
|
||||
CONFIG['domain'][domain] = {
|
||||
'enabled': True,
|
||||
'name': domain
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
CONFIG['dryrun'] = True
|
||||
|
||||
CONFIG['domain'][domain]['enabled'] = True
|
||||
port = CONFIG['domain'][domain].get('port', 3000)
|
||||
|
||||
uvicorn.run(
|
||||
'halfapi.app:application',
|
||||
port=port
|
||||
)
|
||||
|
||||
|
||||
sys.exit(0)
|
||||
|
|
|
@ -32,7 +32,7 @@ from timing_asgi.integrations import StarletteScopeToName
|
|||
from .lib.constants import API_SCHEMA_DICT
|
||||
from .lib.domain_middleware import DomainMiddleware
|
||||
from .lib.timing import HTimingClient
|
||||
from .lib.jwt_middleware import JWTAuthenticationBackend
|
||||
from .lib.jwt_middleware import JWTAuthenticationBackend, on_auth_error
|
||||
from .lib.responses import (ORJSONResponse, UnauthorizedResponse,
|
||||
NotFoundResponse, InternalServerErrorResponse, NotImplementedResponse,
|
||||
ServiceUnavailableResponse, gen_exception_route)
|
||||
|
@ -66,10 +66,14 @@ class HalfAPI(Starlette):
|
|||
)
|
||||
|
||||
logger.info('Config: %s', self.config)
|
||||
logger.info('Active domains: %s',
|
||||
list(filter(
|
||||
lambda n: n.get('enabled', False),
|
||||
self.config.get('domain', {}).values())))
|
||||
|
||||
domains = {
|
||||
key: elt
|
||||
for key, elt in self.config.get('domain', {}).items()
|
||||
if elt.get('enabled', False)
|
||||
}
|
||||
|
||||
logger.info('Active domains: %s', domains)
|
||||
|
||||
if d_routes:
|
||||
# Mount the routes from the d_routes argument - domain-less mode
|
||||
|
@ -103,7 +107,7 @@ class HalfAPI(Starlette):
|
|||
|
||||
self.__domains = {}
|
||||
|
||||
for key, domain in self.config.get('domain', {}).items():
|
||||
for key, domain in domains.items():
|
||||
if not isinstance(domain, dict):
|
||||
continue
|
||||
|
||||
|
@ -112,7 +116,7 @@ class HalfAPI(Starlette):
|
|||
continue
|
||||
|
||||
if not domain.get('prefix', False):
|
||||
if len(self.config.get('domain').keys()) > 1:
|
||||
if len(domains.keys()) > 1:
|
||||
raise Exception('Cannot use multiple domains and set prefix to false')
|
||||
path = '/'
|
||||
else:
|
||||
|
@ -137,7 +141,8 @@ class HalfAPI(Starlette):
|
|||
if SECRET:
|
||||
self.add_middleware(
|
||||
AuthenticationMiddleware,
|
||||
backend=JWTAuthenticationBackend()
|
||||
backend=JWTAuthenticationBackend(),
|
||||
on_error=on_auth_error
|
||||
)
|
||||
|
||||
if not PRODUCTION:
|
||||
|
|
|
@ -14,16 +14,18 @@ from os import environ
|
|||
import typing
|
||||
from uuid import UUID
|
||||
|
||||
from http.cookies import SimpleCookie
|
||||
import jwt
|
||||
from starlette.authentication import (
|
||||
AuthenticationBackend, AuthenticationError, BaseUser, AuthCredentials,
|
||||
UnauthenticatedUser)
|
||||
from starlette.requests import HTTPConnection
|
||||
from starlette.requests import HTTPConnection, Request
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from .user import CheckUser, JWTUser, Nobody
|
||||
from ..logging import logger
|
||||
from ..conf import CONFIG
|
||||
from ..lib.responses import ORJSONResponse
|
||||
|
||||
SECRET=None
|
||||
|
||||
|
@ -34,6 +36,20 @@ except FileNotFoundError:
|
|||
logger.error('Could not import SECRET variable from conf module,'\
|
||||
' using HALFAPI_SECRET environment variable')
|
||||
|
||||
def cookies_from_scope(scope):
|
||||
cookie = dict(scope.get("headers") or {}).get(b"cookie")
|
||||
if not cookie:
|
||||
return {}
|
||||
|
||||
simple_cookie = SimpleCookie()
|
||||
simple_cookie.load(cookie.decode("utf8"))
|
||||
return {key: morsel.value for key, morsel in simple_cookie.items()}
|
||||
|
||||
def on_auth_error(request: Request, exc: Exception):
|
||||
response = ORJSONResponse({"error": str(exc)}, status_code=401)
|
||||
response.delete_cookie('Authorization')
|
||||
return response
|
||||
|
||||
class JWTAuthenticationBackend(AuthenticationBackend):
|
||||
def __init__(self, secret_key: str = SECRET,
|
||||
algorithm: str = 'HS256', prefix: str = 'JWT'):
|
||||
|
@ -52,17 +68,22 @@ class JWTAuthenticationBackend(AuthenticationBackend):
|
|||
self, conn: HTTPConnection
|
||||
) -> typing.Optional[typing.Tuple['AuthCredentials', 'BaseUser']]:
|
||||
|
||||
|
||||
# Standard way to authenticate via API
|
||||
# https://datatracker.ietf.org/doc/html/rfc7235#section-4.2
|
||||
token = conn.headers.get('Authorization')
|
||||
|
||||
if not token:
|
||||
token = cookies_from_scope(conn.scope).get('Authorization')
|
||||
|
||||
is_check_call = 'check' in conn.query_params
|
||||
is_fake_user_id = is_check_call and 'user_id' in conn.query_params
|
||||
|
||||
PRODUCTION = conn.scope['app'].debug == False
|
||||
|
||||
if not token and not is_check_call:
|
||||
return AuthCredentials(), Nobody()
|
||||
|
||||
try:
|
||||
if token and not is_fake_user_id:
|
||||
if token:
|
||||
payload = jwt.decode(token,
|
||||
key=self.secret_key,
|
||||
algorithms=[self.algorithm],
|
||||
|
@ -71,14 +92,6 @@ class JWTAuthenticationBackend(AuthenticationBackend):
|
|||
})
|
||||
|
||||
if is_check_call:
|
||||
if is_fake_user_id:
|
||||
try:
|
||||
fake_user_id = UUID(conn.query_params['user_id'])
|
||||
|
||||
return AuthCredentials(), CheckUser(fake_user_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, 'user_id parameter not an uuid')
|
||||
|
||||
if token:
|
||||
return AuthCredentials(), CheckUser(payload['user_id'])
|
||||
|
||||
|
@ -89,6 +102,8 @@ class JWTAuthenticationBackend(AuthenticationBackend):
|
|||
raise AuthenticationError(
|
||||
'Trying to connect using *DEBUG* token in *PRODUCTION* mode')
|
||||
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
return AuthCredentials(), Nobody()
|
||||
except jwt.InvalidTokenError as exc:
|
||||
raise AuthenticationError(str(exc)) from exc
|
||||
except Exception as exc:
|
||||
|
|
|
@ -98,7 +98,7 @@ class TestDomain(TestCase):
|
|||
try:
|
||||
result = self.runner.invoke(cli, '--version')
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
result = self.runner.invoke(cli, ['domain', self.DOMAIN, self.config_file])
|
||||
result = self.runner.invoke(cli, ['domain', '--read', self.DOMAIN, self.config_file])
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
result_d = json.loads(result.stdout)
|
||||
result = self.runner.invoke(cli, ['run', '--help'])
|
||||
|
|
|
@ -39,7 +39,7 @@ class TestCliProj():
|
|||
})
|
||||
)
|
||||
|
||||
r = project_runner(f'domain dummy_domain {tmp_conf}')
|
||||
r = project_runner(f'domain dummy_domain --dry-run {tmp_conf}')
|
||||
print(r.stdout)
|
||||
assert r.exit_code == 0
|
||||
|
||||
|
|
|
@ -50,6 +50,12 @@ def test_jwt_Token(dummy_app, token_builder):
|
|||
dummy_app.add_route('/test', test_route)
|
||||
test_client = TestClient(dummy_app)
|
||||
|
||||
resp = test_client.request('get', '/test',
|
||||
cookies={
|
||||
'Authorization': token_builder
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = test_client.request('get', '/test',
|
||||
headers={
|
||||
'Authorization': token_builder
|
||||
|
@ -57,6 +63,7 @@ def test_jwt_Token(dummy_app, token_builder):
|
|||
assert resp.status_code == 200
|
||||
|
||||
|
||||
|
||||
def test_jwt_DebugFalse(dummy_app, token_debug_false_builder):
|
||||
async def test_route(request):
|
||||
assert isinstance(request.user, JWTUser)
|
||||
|
@ -65,6 +72,12 @@ def test_jwt_DebugFalse(dummy_app, token_debug_false_builder):
|
|||
dummy_app.add_route('/test', test_route)
|
||||
test_client = TestClient(dummy_app)
|
||||
|
||||
resp = test_client.request('get', '/test',
|
||||
cookies={
|
||||
'Authorization': token_debug_false_builder
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = test_client.request('get', '/test',
|
||||
headers={
|
||||
'Authorization': token_debug_false_builder
|
||||
|
@ -82,6 +95,12 @@ def test_jwt_DebugTrue(dummy_app, token_debug_true_builder):
|
|||
dummy_app.add_route('/test', test_route)
|
||||
test_client = TestClient(dummy_app)
|
||||
|
||||
resp = test_client.request('get', '/test',
|
||||
cookies={
|
||||
'Authorization': token_debug_true_builder
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
resp = test_client.request('get', '/test',
|
||||
headers={
|
||||
'Authorization': token_debug_true_builder
|
||||
|
@ -101,7 +120,13 @@ def test_jwt_DebugTrue_DebugApp(dummy_debug_app, token_debug_true_builder):
|
|||
test_client = TestClient(dummy_debug_app)
|
||||
|
||||
resp = test_client.request('get', '/test',
|
||||
headers={
|
||||
'Authorization': token_debug_true_builder
|
||||
cookies={
|
||||
'Authorization': token_debug_true_builder
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = test_client.request('get', '/test',
|
||||
headers={
|
||||
'Authorization': token_debug_true_builder
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
|
Loading…
Reference in New Issue