Compare commits

...

12 Commits

Author SHA1 Message Date
Maxime Alves LIRMM f6d08e8309 [release] 0.6.25 2023-02-20 12:18:38 +01:00
Maxime Alves LIRMM 262de901a8 [jwtMiddleware] on expired signature error, returns Nobody and do not raise an exception 2023-02-13 11:14:24 +01:00
Maxime Alves LIRMM e5c25ede1f [rc] 0.6.25-rc0 2023-02-08 12:54:35 +01:00
Maxime Alves LIRMM b4c37ea999 [release] 0.6.24 2023-02-03 14:22:04 +01:00
Maxime Alves LIRMM 5a7e51ae94 [jwtMiddleware] clean "is_fake_user_id" code 2023-02-03 14:20:01 +01:00
Maxime Alves LIRMM 69129fd7af [doc][release] 0.7.0 2023-02-03 12:43:30 +01:00
Maxime Alves LIRMM a3fc6dc830 [authMiddleware] UN-Breaking uses either the cookie or the header names "Authorization" 2023-02-03 12:43:16 +01:00
Maxime Alves LIRMM@home 064127dc16 [fix] tests for running by implementing the dry-run option to the domain command 2023-02-03 00:02:56 +01:00
Maxime Alves LIRMM@home c2eb95533c [fix] fix check_domain test c9ecc1f8d2 2023-02-02 22:33:35 +01:00
Maxime Alves LIRMM bbb027cd0d [authMiddleware][BREAKING] read token from "JWTToken" cookie 2023-02-02 19:56:59 +01:00
Maxime Alves LIRMM c9ecc1f8d2 [cli] allow to run halfapi on a specified domain using the "halfapi domain --run DOMAIN" command 2023-02-02 19:56:11 +01:00
Maxime Alves LIRMM d1a8351997 [halfapi] if there is only one domain it is automatically enabled 2023-02-02 19:55:37 +01:00
9 changed files with 145 additions and 40 deletions

View File

@ -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

View File

@ -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

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
__version__ = '0.6.23'
__version__ = '0.6.25'
def version():
return f'HalfAPI version:{__version__}'

View File

@ -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)

View File

@ -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:

View File

@ -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:

View File

@ -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'])

View File

@ -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

View File

@ -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