From 5c15c1b7e22ba8e2b84e8fcdc42866164cf9b52f Mon Sep 17 00:00:00 2001 From: Ramiro Medina <64783088+ramedina86@users.noreply.github.com> Date: Wed, 12 Jun 2024 12:04:43 +0000 Subject: [PATCH 1/7] fix: Dropdown bug --- src/ui/src/core_components/input/CoreDropdownInput.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/src/core_components/input/CoreDropdownInput.vue b/src/ui/src/core_components/input/CoreDropdownInput.vue index 28277bc3c..d77aac8e4 100644 --- a/src/ui/src/core_components/input/CoreDropdownInput.vue +++ b/src/ui/src/core_components/input/CoreDropdownInput.vue @@ -5,7 +5,7 @@ class="CoreDropdownInput" > Date: Wed, 12 Jun 2024 12:20:27 +0000 Subject: [PATCH 3/7] fix: Remove unnecessary print statement --- src/writer/command_line.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/writer/command_line.py b/src/writer/command_line.py index 1ac3c8169..a6b2d767a 100644 --- a/src/writer/command_line.py +++ b/src/writer/command_line.py @@ -45,7 +45,6 @@ def main(): args.path) if args.path else None host = args.host if args.host else None api_key = args.api_key if args.api_key else None - print(args.env) _perform_checks(command, absolute_app_path, host, enable_remote_edit, api_key) api_key = _get_api_key(command, api_key) From 08e563c42be4852b87accf5c901bf20187ed4f57 Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Mon, 27 May 2024 19:39:49 +0200 Subject: [PATCH 4/7] feat: implement authentication workflow using basic auth * feat: implement auth.BasicAuth * feat: implement simple bruteforce mitigation --- src/writer/auth.py | 122 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 118 insertions(+), 4 deletions(-) diff --git a/src/writer/auth.py b/src/writer/auth.py index cec780e5a..bc768ce8f 100644 --- a/src/writer/auth.py +++ b/src/writer/auth.py @@ -1,12 +1,14 @@ +import asyncio import dataclasses import os.path +import time from abc import ABCMeta, abstractmethod -from typing import Callable, Optional +from typing import Callable, Dict, Optional from urllib.parse import urlparse from authlib.integrations.requests_client.oauth2_session import OAuth2Session # type: ignore -from fastapi import Request, Response -from fastapi.responses import RedirectResponse +from fastapi import Request, Response, status +from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates import writer.serve @@ -14,6 +16,8 @@ from writer.serve import WriterFastAPI from writer.ss_types import InitSessionRequestPayload +# Dictionary for storing failed attempts {ip_address: timestamp} +failed_attempts: Dict[str, float] = {} class Unauthorized(Exception): """ @@ -35,12 +39,106 @@ class Auth: @abstractmethod def register(self, - app: WriterFastAPI, + asgi_app: WriterFastAPI, callback: Optional[Callable[[Request, str, dict], None]] = None, unauthorized_action: Optional[Callable[[Request, Unauthorized], Response]] = None ): raise NotImplementedError +@dataclasses.dataclass +class BasicAuth(Auth): + """ + Configure Writer Framework to use Basic Authentication. If this is set, Writer Framework will + ask anonymous users to authenticate using Basic Authentication. + + >>> _auth = auth.BasicAuth( + >>> login=os.getenv('LOGIN'), + >>> password=os.getenv('PASSWORD') + >>> ) + >>> writer.server.register_auth(_auth) + + Brute force protection + ---------------------- + + A simple brute force protection is implemented by default. If a user fails to log in, the IP of this user is blocked. + Writer framework will ban the IP from either the `X-Forwarded-For` header or the `X-Real-IP` header or the client IP address. + + When a user fails to log in, they wait 1 second before they can try again. This time can be modified by + modifying the value of `delay_after_failure`. + + >>> _auth = auth.BasicAuth( + >>> login=os.getenv('LOGIN'), + >>> password=os.getenv('PASSWORD') + >>> delay_after_failure=5 # 5 seconds delay after a failed login + >>> ) + >>> writer.server.register_auth(_auth) + + The user is stuck by default after a failure. + + >>> _auth = auth.BasicAuth( + >>> login=os.getenv('LOGIN'), + >>> password=os.getenv('PASSWORD'), + >>> delay_after_failure=5, + >>> block_webserver_after_failure=False + >>> ) + """ + login: str + password: str + delay_after_failure: int = 1 # limit attempt when authentication fail (reduce brute force risk) + block_user_after_failure: bool = True # delay the answer to the user after a failed login + + callback_func: Optional[Callable[[Request, str, dict], None]] = None # Callback to validate user authentication + unauthorized_action: Optional[Callable[[Request, Unauthorized], Response]] = None # Callback to build its own page when a user is not allowed + + + def register(self, + asgi_app: WriterFastAPI, + callback: Optional[Callable[[Request, str, dict], None]] = None, + unauthorized_action: Optional[Callable[[Request, Unauthorized], Response]] = None): + + @asgi_app.middleware("http") + async def basicauth_middleware(request: Request, call_next): + import base64 + client_ip = _client_ip(request) + + try: + if client_ip in failed_attempts and time.time() - failed_attempts[client_ip] < self.delay_after_failure: + remaining_time = int(self.delay_after_failure - (time.time() - failed_attempts[client_ip])) + raise Unauthorized(status_code=429, message="Too Many Requests", more_info=f"You can try to log in every {self.delay_after_failure}s. Your next try is in {remaining_time}s.") + + session_id = session_manager.generate_session_id() + _auth = request.headers.get('Authorization') + if _auth is None: + return HTMLResponse("", status.HTTP_401_UNAUTHORIZED, {"WWW-Authenticate": "Basic"}) + + scheme, data = (_auth or ' ').split(' ', 1) + if scheme != 'Basic': + return HTMLResponse("", status.HTTP_401_UNAUTHORIZED, {"WWW-Authenticate": "Basic"}) + + username, password = base64.b64decode(data).decode().split(':', 1) + if self.callback_func: + self.callback_func(request, session_id, {'username': username}) + else: + if username != self.login or password != self.password: + raise Unauthorized() + + return await call_next(request) + except Unauthorized as exc: + if exc.status_code != 429: + failed_attempts[client_ip] = time.time() + + if self.block_user_after_failure: + await asyncio.sleep(self.delay_after_failure) + + if self.unauthorized_action is not None: + return self.unauthorized_action(request, exc) + else: + templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), "templates")) + return templates.TemplateResponse(request=request, name="auth_unauthorized.html", status_code=exc.status_code, context={ + "status_code": exc.status_code, + "message": exc.message, + "more_info": exc.more_info + }) @dataclasses.dataclass class Oidc(Auth): @@ -243,3 +341,19 @@ def _urlstrip(url_path: str): >>> "http://localhost/app1" """ return url_path.strip('/') + +def _client_ip(request: Request) -> str: + """ + Get the client IP address from the request. + + >>> _client_ip(request) + """ + x_forwarded_for = request.headers.get("X-Forwarded-For") + if x_forwarded_for: + # X-Forwarded-For can contain a list of IPs, the first is the real IP of the client + ip = x_forwarded_for.split(",")[0].strip() + else: + # Otherwise, use the direct connection IP + ip = request.headers.get("X-Real-IP", request.client.host) # type: ignore + + return ip From 9144e6c4b0acb126721f5cc678b05254d77c02ce Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Mon, 27 May 2024 21:42:05 +0200 Subject: [PATCH 5/7] feat: implement authentication workflow using basic auth * chore: add test on basic auth feature --- tests/backend/__init__.py | 1 + tests/backend/test_auth.py | 28 +++++++++++++++++++++ tests/backend/testbasicauth/main.py | 4 +++ tests/backend/testbasicauth/server_setup.py | 10 ++++++++ tests/backend/testbasicauth/static/file.js | 0 tests/backend/testbasicauth/ui.json | 16 ++++++++++++ 6 files changed, 59 insertions(+) create mode 100644 tests/backend/test_auth.py create mode 100644 tests/backend/testbasicauth/main.py create mode 100644 tests/backend/testbasicauth/server_setup.py create mode 100644 tests/backend/testbasicauth/static/file.js create mode 100644 tests/backend/testbasicauth/ui.json diff --git a/tests/backend/__init__.py b/tests/backend/__init__.py index 942382f1c..180de2413 100644 --- a/tests/backend/__init__.py +++ b/tests/backend/__init__.py @@ -2,3 +2,4 @@ test_app_dir = Path(__file__).resolve().parent / 'testapp' test_multiapp_dir = Path(__file__).resolve().parent / 'testmultiapp' +test_basicauth_dir = Path(__file__).resolve().parent / 'testbasicauth' diff --git a/tests/backend/test_auth.py b/tests/backend/test_auth.py new file mode 100644 index 000000000..6e7aa6f69 --- /dev/null +++ b/tests/backend/test_auth.py @@ -0,0 +1,28 @@ +import fastapi +import fastapi.testclient +import writer.serve + +from tests.backend import test_basicauth_dir + + +class TestAuth: + + def test_basicauth_authentication_module_should_ask_user_to_write_basic_auth(self): + """ + This test verifies that a user has to authenticate when the basic auth module is active. + + """ + asgi_app: fastapi.FastAPI = writer.serve.get_asgi_app(test_basicauth_dir, "run", enable_server_setup=True) + with fastapi.testclient.TestClient(asgi_app) as client: + res = client.get("/api/init") + assert res.status_code == 401 + + def test_basicauth_authentication_module_should_accept_user_using_authorization(self): + """ + This test verifies that a user can use the application when providing basic auth credentials. + + """ + asgi_app: fastapi.FastAPI = writer.serve.get_asgi_app(test_basicauth_dir, "run", enable_server_setup=True) + with fastapi.testclient.TestClient(asgi_app) as client: + res = client.get("/static/file.js", auth=("admin", "admin")) + assert res.status_code == 200 diff --git a/tests/backend/testbasicauth/main.py b/tests/backend/testbasicauth/main.py new file mode 100644 index 000000000..fc036cfe4 --- /dev/null +++ b/tests/backend/testbasicauth/main.py @@ -0,0 +1,4 @@ +import writer as wf + +initial_state = wf.init_state({ +}) diff --git a/tests/backend/testbasicauth/server_setup.py b/tests/backend/testbasicauth/server_setup.py new file mode 100644 index 000000000..3aad409c4 --- /dev/null +++ b/tests/backend/testbasicauth/server_setup.py @@ -0,0 +1,10 @@ +import writer.auth +import writer.serve + +_auth = writer.auth.BasicAuth( + login='admin', + password='admin', + delay_after_failure=0 +) + +writer.serve.register_auth(_auth) diff --git a/tests/backend/testbasicauth/static/file.js b/tests/backend/testbasicauth/static/file.js new file mode 100644 index 000000000..e69de29bb diff --git a/tests/backend/testbasicauth/ui.json b/tests/backend/testbasicauth/ui.json new file mode 100644 index 000000000..25b939367 --- /dev/null +++ b/tests/backend/testbasicauth/ui.json @@ -0,0 +1,16 @@ +{ + "metadata": { + "writer_version": "0.5.0" + }, + "components": { + "root": { + "id": "root", + "type": "root", + "content": { + "appName": "My App" + }, + "isCodeManaged": false, + "position": 0 + } + } +} From bb6d8cf6d893426edd5b60567bfe5e4892434cc8 Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Wed, 5 Jun 2024 06:52:18 +0200 Subject: [PATCH 6/7] feat: implement authentication workflow using basic auth * write documentation about basic auth --- docs/docs/authentication.md | 111 ++++++++++++++------- docs/docs/images/auth_too_many_request.png | Bin 0 -> 20485 bytes 2 files changed, 74 insertions(+), 37 deletions(-) create mode 100644 docs/docs/images/auth_too_many_request.png diff --git a/docs/docs/authentication.md b/docs/docs/authentication.md index 3e543c23e..9cf3d8664 100644 --- a/docs/docs/authentication.md +++ b/docs/docs/authentication.md @@ -1,13 +1,50 @@ # Authentication -The StreamSync authentication module allows you to restrict access to your application. +The Writer framework authentication module allows you to restrict access to your application. -Streamsync will be able to authenticate a user through an identity provider such as Google, Microsoft, Facebook, Github, Auth0, etc. +Writer framework offers either simple password authentication or identity provider authentication (Google, Microsoft, Facebook, Github, Auth0, etc.). ::: warning Authentication is done before accessing the application Authentication is done before accessing the application. It is not possible to trigger authentication for certain pages exclusively. ::: +## Use Basic Auth + +Basic Auth is a simple authentication method that uses a username and password. Authentication configuration is done in [the `server_setup.py` module](custom-server.md). + +::: warning Password authentication is not safe for critical application +Basic Auth authentication is not secure for critical applications. + +A user can intercept the plaintext password if https encryption fails. +It may also try to force password using brute force attacks. + +For added security, it's recommended to use identity provider (Google, Microsoft, Facebook, Github, Auth0, etc.). +::: + +*server_setup.py* +```python +import os +import writer.serve +import writer.auth + +auth = writer.auth.BasicAuth( + login=os.get('LOGIN'), + password=os.get('PASSWORD'), +) + +writer.serve.register_auth(auth) +``` + +### Brute force protection + +A simple brute force protection is implemented by default. If a user fails to log in, the IP of this user is blocked. +Writer framework will ban the IP from either the X-Forwarded-For header or the X-Real-IP header or the client IP address. + +When a user fails to log in, they wait 1 second before they can try again. This time can be modified by +modifying the value of delay_after_failure. + + + ## Use OIDC provider Authentication configuration is done in [the `server_setup.py` module](custom-server.md). The configuration depends on your identity provider. @@ -18,10 +55,10 @@ Here is an example configuration for Google. *server_setup.py* ```python import os -import streamsync.serve -import streamsync.auth +import writer.serve +import writer.auth -oidc = streamsync.auth.Oidc( +oidc = writer.auth.Oidc( client_id="1xxxxxxxxx-qxxxxxxxxxxxxxxx.apps.googleusercontent.com", client_secret="GOxxxx-xxxxxxxxxxxxxxxxxxxxx", host_url=os.getenv('HOST_URL', "http://localhost:5000"), @@ -30,17 +67,17 @@ oidc = streamsync.auth.Oidc( url_userinfo='https://www.googleapis.com/oauth2/v1/userinfo?alt=json' ) -streamsync.serve.register_auth(oidc) +writer.serve.register_auth(oidc) ``` ### Use pre-configured OIDC -StreamSync provides pre-configured OIDC providers. You can use them directly in your application. +Writer framework provides pre-configured OIDC providers. You can use them directly in your application. -| | Provider | Function | Description | -|------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|----------|-------------------------------------------------------------------------------------------------| -| | Google | `streamsync.auth.Google` | Allow your users to login with their Google Account | -| | Github | `streamsync.auth.Github` | Allow your users to login with their Github Account | -| | Auth0 | `streamsync.auth.Auth0` | Allow your users to login with different providers or with login password through Auth0 | +| | Provider | Function | Description | +|------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|-------------------------|-------------------------------------------------------------------------------------------------| +| | Google | `writer.auth.Google` | Allow your users to login with their Google Account | +| | Github | `writer.auth.Github` | Allow your users to login with their Github Account | +| | Auth0 | `writer.auth.Auth0` | Allow your users to login with different providers or with login password through Auth0 | #### Google @@ -50,16 +87,16 @@ You have to register your application into [Google Cloud Console](https://consol *server_setup.py* ```python import os -import streamsync.serve -import streamsync.auth +import writer.serve +import writer.auth -oidc = streamsync.auth.Google( +oidc = writer.auth.Google( client_id="1xxxxxxxxx-qxxxxxxxxxxxxxxx.apps.googleusercontent.com", client_secret="GOxxxx-xxxxxxxxxxxxxxxxxxxxx", host_url=os.getenv('HOST_URL', "http://localhost:5000") ) -streamsync.serve.register_auth(oidc) +writer.serve.register_auth(oidc) ``` #### Github @@ -69,16 +106,16 @@ You have to register your application into [Github](https://docs.github.com/en/a *server_setup.py* ```python import os -import streamsync.serve -import streamsync.auth +import writer.serve +import writer.auth -oidc = streamsync.auth.Github( +oidc = writer.auth.Github( client_id="xxxxxxx", client_secret="xxxxxxxxxxxxx", host_url=os.getenv('HOST_URL', "http://localhost:5000") ) -streamsync.serve.register_auth(oidc) +writer.serve.register_auth(oidc) ``` #### Auth0 @@ -88,17 +125,17 @@ You have to register your application into [Auth0](https://auth0.com/). *server_setup.py* ```python import os -import streamsync.serve -import streamsync.auth +import writer.serve +import writer.auth -oidc = streamsync.auth.Auth0( +oidc = writer.auth.Auth0( client_id="xxxxxxx", client_secret="xxxxxxxxxxxxx", domain="xxx-xxxxx.eu.auth0.com", host_url=os.getenv('HOST_URL', "http://localhost:5000") ) -streamsync.serve.register_auth(oidc) +writer.serve.register_auth(oidc) ``` ### Authentication workflow @@ -129,23 +166,23 @@ See [User information in event handler](#user-information-in-event-handler) ```python from fastapi import Request -import streamsync.serve -import streamsync.auth +import writer.serve +import writer.auth oidc = ... def callback(request: Request, session_id: str, userinfo: dict): if userinfo['email'] not in ['nom.prenom123@example.com']: - raise streamsync.auth.Unauthorized(more_info="You can contact the administrator at support.example.com") + raise writer.auth.Unauthorized(more_info="You can contact the administrator at support.example.com") -streamsync.serve.register_auth(oidc, callback=callback) +writer.serve.register_auth(oidc, callback=callback) ``` The default authentication error page look like this: -*streamsync.auth.Unauthorized* +*writer.auth.Unauthorized* | Parameter | Description | |-----------|-------------| @@ -160,8 +197,8 @@ User info can be modified in the callback. ```python from fastapi import Request -import streamsync.serve -import streamsync.auth +import writer.serve +import writer.auth oidc = ... @@ -173,7 +210,7 @@ def callback(request: Request, session_id: str, userinfo: dict): else: userinfo['group'].append('user') -streamsync.serve.register_auth(oidc, callback=callback) +writer.serve.register_auth(oidc, callback=callback) ``` from fastapi import Request @@ -187,12 +224,12 @@ import os from fastapi import Request, Response from fastapi.templating import Jinja2Templates -import streamsync.serve -import streamsync.auth +import writer.serve +import writer.auth oidc = ... -def unauthorized(request: Request, exc: streamsync.auth.Unauthorized) -> Response: +def unauthorized(request: Request, exc: writer.auth.Unauthorized) -> Response: templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), "templates")) return templates.TemplateResponse(request=request, name="unauthorized.html", status_code=exc.status_code, context={ "status_code": exc.status_code, @@ -200,7 +237,7 @@ def unauthorized(request: Request, exc: streamsync.auth.Unauthorized) -> Respons "more_info": exc.more_info }) -streamsync.serve.register_auth(oidc, unauthorized_action=unauthorized) +writer.serve.register_auth(oidc, unauthorized_action=unauthorized) ``` ## Enable in edit mode @@ -209,6 +246,6 @@ Authentication is disabled in edit mode. To activate it, you must trigger the loading of the server_setup module in edition mode. ```bash -streamsync edit --enable-server-setup +writer edit --enable-server-setup ``` diff --git a/docs/docs/images/auth_too_many_request.png b/docs/docs/images/auth_too_many_request.png new file mode 100644 index 0000000000000000000000000000000000000000..d2962c937e071e1547ca262dd7fd875a74fce326 GIT binary patch literal 20485 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 literal 0 HcmV?d00001 From 9b04c5024d33e7b6b129d2f90c13c68a4fe80c72 Mon Sep 17 00:00:00 2001 From: Ramiro Medina <64783088+ramedina86@users.noreply.github.com> Date: Thu, 13 Jun 2024 10:43:19 +0000 Subject: [PATCH 7/7] fix: Unauth template --- src/writer/templates/auth_unauthorized.html | 78 ++++++++++++++------- 1 file changed, 52 insertions(+), 26 deletions(-) diff --git a/src/writer/templates/auth_unauthorized.html b/src/writer/templates/auth_unauthorized.html index 460e57a8d..ca22e6f23 100644 --- a/src/writer/templates/auth_unauthorized.html +++ b/src/writer/templates/auth_unauthorized.html @@ -1,35 +1,61 @@ - + -
-

{{status_code}} {{message}}

-

{{more_info | safe}}

-
+
+

{{status_code}}

+

{{message}}

+
{{more_info | safe}}
+ Drawing of sad woman +
- + + \ No newline at end of file