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"
>
handleInput(
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
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}}
+
+
-
+
+