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

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

View File

@ -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__}'

View File

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

View File

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

View File

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

View File

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

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) print(r.stdout)
assert r.exit_code == 0 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) 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