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
|
# 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
|
## 0.6.23
|
||||||
|
|
||||||
Dependency update version
|
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 .
|
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
|
### Project
|
||||||
|
|
||||||
The main configuration options without which HalfAPI cannot be run.
|
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.
|
**secret** : The file containing the secret to decode the user's tokens.
|
||||||
|
|
||||||
**port** : The port for the test server.
|
**port** : The port for the test server.
|
||||||
|
@ -43,12 +41,28 @@ The main configuration options without which HalfAPI cannot be run.
|
||||||
|
|
||||||
### Domains
|
### Domains
|
||||||
|
|
||||||
The name of the options should be the name of the domains' module, the value is the
|
Specify the domains configurations in the following form :
|
||||||
submodule which contains the routers.
|
|
||||||
|
|
||||||
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
|
## Usage
|
||||||
|
@ -62,9 +76,17 @@ Run the project by using the `halfapi run` command.
|
||||||
You can try the dummy_domain with the following 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
|
## API Testing
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
__version__ = '0.6.23'
|
__version__ = '0.6.25'
|
||||||
|
|
||||||
def version():
|
def version():
|
||||||
return f'HalfAPI version:{__version__}'
|
return f'HalfAPI version:{__version__}'
|
||||||
|
|
|
@ -12,6 +12,7 @@ import json
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import orjson
|
import orjson
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
|
||||||
from .cli import cli
|
from .cli import cli
|
||||||
|
@ -118,14 +119,16 @@ def list_api_routes():
|
||||||
# list_routes(domain, m_dom)
|
# 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('--create',default=False, is_flag=True)
|
||||||
@click.option('--update',default=False, is_flag=True)
|
@click.option('--update',default=False, is_flag=True)
|
||||||
@click.option('--delete',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('config_file', type=click.File(mode='rb'), required=False)
|
||||||
@click.argument('domain',default=None, required=False)
|
@click.argument('domain',default=None, required=False)
|
||||||
@cli.command()
|
@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
|
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
|
# TODO: Connect to the create_domain function
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
raise Exception('Missing domain name')
|
raise Exception('Missing domain name')
|
||||||
if update:
|
if create:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
if delete:
|
elif update:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
if read:
|
elif delete:
|
||||||
|
raise NotImplementedError
|
||||||
|
elif read:
|
||||||
from ..conf import CONFIG
|
from ..conf import CONFIG
|
||||||
from ..halfapi import HalfAPI
|
from ..halfapi import HalfAPI
|
||||||
|
|
||||||
|
@ -162,4 +167,27 @@ def domain(domain, config_file, delete, update, create, read): #, domains, read
|
||||||
default=ORJSONResponse.default_cast)
|
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)
|
sys.exit(0)
|
||||||
|
|
|
@ -32,7 +32,7 @@ from timing_asgi.integrations import StarletteScopeToName
|
||||||
from .lib.constants import API_SCHEMA_DICT
|
from .lib.constants import API_SCHEMA_DICT
|
||||||
from .lib.domain_middleware import DomainMiddleware
|
from .lib.domain_middleware import DomainMiddleware
|
||||||
from .lib.timing import HTimingClient
|
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,
|
from .lib.responses import (ORJSONResponse, UnauthorizedResponse,
|
||||||
NotFoundResponse, InternalServerErrorResponse, NotImplementedResponse,
|
NotFoundResponse, InternalServerErrorResponse, NotImplementedResponse,
|
||||||
ServiceUnavailableResponse, gen_exception_route)
|
ServiceUnavailableResponse, gen_exception_route)
|
||||||
|
@ -66,10 +66,14 @@ class HalfAPI(Starlette):
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info('Config: %s', self.config)
|
logger.info('Config: %s', self.config)
|
||||||
logger.info('Active domains: %s',
|
|
||||||
list(filter(
|
domains = {
|
||||||
lambda n: n.get('enabled', False),
|
key: elt
|
||||||
self.config.get('domain', {}).values())))
|
for key, elt in self.config.get('domain', {}).items()
|
||||||
|
if elt.get('enabled', False)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Active domains: %s', domains)
|
||||||
|
|
||||||
if d_routes:
|
if d_routes:
|
||||||
# Mount the routes from the d_routes argument - domain-less mode
|
# Mount the routes from the d_routes argument - domain-less mode
|
||||||
|
@ -103,7 +107,7 @@ class HalfAPI(Starlette):
|
||||||
|
|
||||||
self.__domains = {}
|
self.__domains = {}
|
||||||
|
|
||||||
for key, domain in self.config.get('domain', {}).items():
|
for key, domain in domains.items():
|
||||||
if not isinstance(domain, dict):
|
if not isinstance(domain, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
@ -112,7 +116,7 @@ class HalfAPI(Starlette):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not domain.get('prefix', False):
|
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')
|
raise Exception('Cannot use multiple domains and set prefix to false')
|
||||||
path = '/'
|
path = '/'
|
||||||
else:
|
else:
|
||||||
|
@ -137,7 +141,8 @@ class HalfAPI(Starlette):
|
||||||
if SECRET:
|
if SECRET:
|
||||||
self.add_middleware(
|
self.add_middleware(
|
||||||
AuthenticationMiddleware,
|
AuthenticationMiddleware,
|
||||||
backend=JWTAuthenticationBackend()
|
backend=JWTAuthenticationBackend(),
|
||||||
|
on_error=on_auth_error
|
||||||
)
|
)
|
||||||
|
|
||||||
if not PRODUCTION:
|
if not PRODUCTION:
|
||||||
|
|
|
@ -14,16 +14,18 @@ from os import environ
|
||||||
import typing
|
import typing
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from http.cookies import SimpleCookie
|
||||||
import jwt
|
import jwt
|
||||||
from starlette.authentication import (
|
from starlette.authentication import (
|
||||||
AuthenticationBackend, AuthenticationError, BaseUser, AuthCredentials,
|
AuthenticationBackend, AuthenticationError, BaseUser, AuthCredentials,
|
||||||
UnauthenticatedUser)
|
UnauthenticatedUser)
|
||||||
from starlette.requests import HTTPConnection
|
from starlette.requests import HTTPConnection, Request
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
from .user import CheckUser, JWTUser, Nobody
|
from .user import CheckUser, JWTUser, Nobody
|
||||||
from ..logging import logger
|
from ..logging import logger
|
||||||
from ..conf import CONFIG
|
from ..conf import CONFIG
|
||||||
|
from ..lib.responses import ORJSONResponse
|
||||||
|
|
||||||
SECRET=None
|
SECRET=None
|
||||||
|
|
||||||
|
@ -34,6 +36,20 @@ except FileNotFoundError:
|
||||||
logger.error('Could not import SECRET variable from conf module,'\
|
logger.error('Could not import SECRET variable from conf module,'\
|
||||||
' using HALFAPI_SECRET environment variable')
|
' 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):
|
class JWTAuthenticationBackend(AuthenticationBackend):
|
||||||
def __init__(self, secret_key: str = SECRET,
|
def __init__(self, secret_key: str = SECRET,
|
||||||
algorithm: str = 'HS256', prefix: str = 'JWT'):
|
algorithm: str = 'HS256', prefix: str = 'JWT'):
|
||||||
|
@ -52,17 +68,22 @@ class JWTAuthenticationBackend(AuthenticationBackend):
|
||||||
self, conn: HTTPConnection
|
self, conn: HTTPConnection
|
||||||
) -> typing.Optional[typing.Tuple['AuthCredentials', 'BaseUser']]:
|
) -> 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')
|
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_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
|
PRODUCTION = conn.scope['app'].debug == False
|
||||||
|
|
||||||
if not token and not is_check_call:
|
if not token and not is_check_call:
|
||||||
return AuthCredentials(), Nobody()
|
return AuthCredentials(), Nobody()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if token and not is_fake_user_id:
|
if token:
|
||||||
payload = jwt.decode(token,
|
payload = jwt.decode(token,
|
||||||
key=self.secret_key,
|
key=self.secret_key,
|
||||||
algorithms=[self.algorithm],
|
algorithms=[self.algorithm],
|
||||||
|
@ -71,14 +92,6 @@ class JWTAuthenticationBackend(AuthenticationBackend):
|
||||||
})
|
})
|
||||||
|
|
||||||
if is_check_call:
|
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:
|
if token:
|
||||||
return AuthCredentials(), CheckUser(payload['user_id'])
|
return AuthCredentials(), CheckUser(payload['user_id'])
|
||||||
|
|
||||||
|
@ -89,6 +102,8 @@ class JWTAuthenticationBackend(AuthenticationBackend):
|
||||||
raise AuthenticationError(
|
raise AuthenticationError(
|
||||||
'Trying to connect using *DEBUG* token in *PRODUCTION* mode')
|
'Trying to connect using *DEBUG* token in *PRODUCTION* mode')
|
||||||
|
|
||||||
|
except jwt.ExpiredSignatureError as exc:
|
||||||
|
return AuthCredentials(), Nobody()
|
||||||
except jwt.InvalidTokenError as exc:
|
except jwt.InvalidTokenError as exc:
|
||||||
raise AuthenticationError(str(exc)) from exc
|
raise AuthenticationError(str(exc)) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
@ -98,7 +98,7 @@ class TestDomain(TestCase):
|
||||||
try:
|
try:
|
||||||
result = self.runner.invoke(cli, '--version')
|
result = self.runner.invoke(cli, '--version')
|
||||||
self.assertEqual(result.exit_code, 0)
|
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)
|
self.assertEqual(result.exit_code, 0)
|
||||||
result_d = json.loads(result.stdout)
|
result_d = json.loads(result.stdout)
|
||||||
result = self.runner.invoke(cli, ['run', '--help'])
|
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)
|
print(r.stdout)
|
||||||
assert r.exit_code == 0
|
assert r.exit_code == 0
|
||||||
|
|
||||||
|
|
|
@ -50,6 +50,12 @@ def test_jwt_Token(dummy_app, token_builder):
|
||||||
dummy_app.add_route('/test', test_route)
|
dummy_app.add_route('/test', test_route)
|
||||||
test_client = TestClient(dummy_app)
|
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',
|
resp = test_client.request('get', '/test',
|
||||||
headers={
|
headers={
|
||||||
'Authorization': token_builder
|
'Authorization': token_builder
|
||||||
|
@ -57,6 +63,7 @@ def test_jwt_Token(dummy_app, token_builder):
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_jwt_DebugFalse(dummy_app, token_debug_false_builder):
|
def test_jwt_DebugFalse(dummy_app, token_debug_false_builder):
|
||||||
async def test_route(request):
|
async def test_route(request):
|
||||||
assert isinstance(request.user, JWTUser)
|
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)
|
dummy_app.add_route('/test', test_route)
|
||||||
test_client = TestClient(dummy_app)
|
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',
|
resp = test_client.request('get', '/test',
|
||||||
headers={
|
headers={
|
||||||
'Authorization': token_debug_false_builder
|
'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)
|
dummy_app.add_route('/test', test_route)
|
||||||
test_client = TestClient(dummy_app)
|
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',
|
resp = test_client.request('get', '/test',
|
||||||
headers={
|
headers={
|
||||||
'Authorization': token_debug_true_builder
|
'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)
|
test_client = TestClient(dummy_debug_app)
|
||||||
|
|
||||||
resp = test_client.request('get', '/test',
|
resp = test_client.request('get', '/test',
|
||||||
headers={
|
cookies={
|
||||||
'Authorization': token_debug_true_builder
|
'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
|
assert resp.status_code == 200
|
||||||
|
|
Loading…
Reference in New Issue