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 000000000..d2962c937 Binary files /dev/null and b/docs/docs/images/auth_too_many_request.png differ diff --git a/pyproject.toml b/pyproject.toml index 270f40fc0..05c6f710e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "writer" -version = "0.6.0" +version = "0.6.1" description = "An open-source, Python framework for building feature-rich apps that are fully integrated with the Writer platform." authors = ["Writer, Inc."] readme = "README.md" 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" > >> _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 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) 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 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 + } + } +}