diff --git a/docs/advance/decorators.md b/docs/advance/decorators.md
index 78c1b81..f80bdb4 100644
--- a/docs/advance/decorators.md
+++ b/docs/advance/decorators.md
@@ -58,7 +58,8 @@ from .model import User
def admin_only(func: typing.Callable) -> typing.Callable:
- idx = is_route_function(func)
+ # HTTP only
+ idx = is_route_function(func, "request")
if asyncio.iscoroutinefunction(func):
@functools.wraps(func)
diff --git a/docs/custom/login-manager.md b/docs/custom/login-manager.md
index 4385788..d8980a5 100644
--- a/docs/custom/login-manager.md
+++ b/docs/custom/login-manager.md
@@ -2,9 +2,9 @@
`LoginManager` is where we set the configuration for authentication.
-Upon creating the instance, `redirect_to` property must be provided.
-This property is url name of __redirect route__ that authentication is
-required error redirected to.
+When creating the instance, `redirect_to` and `secret_key` argument must be provided.
+This `redirect_to` parameter is url name of __redirect route__ that authentication is
+required error will be redirected to.
`redirect_to` value is either:
@@ -14,12 +14,83 @@ required error redirected to.
```python
from starlette_login.login_manager import LoginManager
-login_manager = LoginManager(redirect_to='login', secret_key='yoursecretkey')
+login_manager = LoginManager(redirect_to='login', secret_key='secretkey')
```
-The `login_manager` instance is going to be passed to **Starlette** application **state**, authentication __Backend__ and __Middleware__.
+The `login_manager` instance is going to be passed to **Starlette**
+application **state**, authentication __Backend__ and __Middleware__.
+## Protection Level
+
+There are 2 protection level `Basic` (_default_) and `Strong`.
+
+Protection level will affect session and cookie session
+when __session identifier__ (hash of user-agent and IP address) changed.
+
+When the session is permanent and __protection level__ is `Strong`,
+then the session will simply be marked as non-fresh,
+and anything requiring a fresh login will force the user
+to re-authenticate while `Basic` will only mark
+the current session as non-`fresh`.
+
+If the identifiers do not match in `Strong` mode for a non-permanent session,
+then the entire session (as well as the remember-token if it exists) is deleted.
+
+__Usage__
+
+```python
+from starlette_login.login_manager import Config, LoginManager, ProtectionLevel
+
+config = Config(protection_level=ProtectionLevel.Strong)
+login_manager = LoginManager(
+ redirect_to='login', secret_key='secretkey', config=config
+)
+```
+
+
+## User Loader Callback
+
+You need to set up `user loader callback` function to load `user` for authentication session.
+
+__Callback signature__
+
+```python
+import typing
+from starlette.requests import Request
+
+# async def / def
+async def load_user(request: Request, user_id: typing.Any):
+ ...
+ return user
+```
+
+__Usage__
+
+```python
+login_manager.set_user_loader(load_user)
+```
+
+## Websocket Authentication Error Callback
+
+If you need to send custom message on `ws_login_required` decorated router,
+You can call `login_manager.set_ws_not_authenticated` method.
+
+By default, authentication error will __close__ the websocket connection.
+
+__Usage__
+
+```python
+from starlette.websockets import WebSocket
+
+async def custom_ws_auth_error(websocket: WebSocket):
+ await websocket.send_text('not authenticated')
+ await websocket.close()
+
+
+login_manager.set_ws_not_authenticated(custom_ws_auth_error)
+```
+
## Config
You can pass custom configuration to `LoginManager` instance to set custom `cookie` values such as:
@@ -33,4 +104,4 @@ You can pass custom configuration to `LoginManager` instance to set custom `cook
- COOKIE_SAMESITE
-See [Configuration](/advance/configuration) section for more information.
+See [Configuration](./configuration.md) section for more information.
diff --git a/docs/custom/middleware.md b/docs/custom/middleware.md
index e69de29..4c7e852 100644
--- a/docs/custom/middleware.md
+++ b/docs/custom/middleware.md
@@ -0,0 +1,10 @@
+## Excluded Path
+
+### Note
+
+`request.user` call will lead to
+`AssertionError: AuthenticationMiddleware must be installed to access request.user`.
+Our authentication middleware is ignoring the excluded path
+and `request.user` is not being set.
+The exception raised by `starlette`.
+You do not need to use `AuthenticationMiddleware` except you need to.
diff --git a/docs/index.md b/docs/index.md
index cad0b51..d7fe79b 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -29,12 +29,6 @@ Development
pip install 'git+https://github.com/jockerz/Starlette-Login'
```
-## Usage Examples
-
- - [Basic Auth](https://github.com/jockerz/Starlette-Login-Example/tree/main/basic_auth)
- - Token Auth: *TODO*
- - Multiple Auth: *TODO*
-
## Usage
@@ -199,7 +193,7 @@ from .model import user_list
from .routes import home_page, login_page, logout_page, protected_page
-login_manager = LoginManager(redirect_to='login')
+login_manager = LoginManager(redirect_to='login', secret_key='secret')
login_manager.set_user_loader(user_list.user_loader)
app = Starlette(
@@ -210,7 +204,6 @@ app = Starlette(
backend=SessionAuthBackend(login_manager),
login_manager=login_manager,
login_route='login',
- secret_key='secret',
)
],
@@ -230,4 +223,11 @@ app.state.login_manager = login_manager
uvicorn app:app
```
+## More Examples
+
+ - [Basic Auth](https://github.com/jockerz/Starlette-Login-Example/tree/main/basic_auth)
+ - Token Auth: *TODO*
+ - Multiple Auth: *TODO*
+
+
[Flask-Login]: https://flask-login.readthedocs.io
diff --git a/docs/middleware.md b/docs/middleware.md
deleted file mode 100644
index 4c7e852..0000000
--- a/docs/middleware.md
+++ /dev/null
@@ -1,10 +0,0 @@
-## Excluded Path
-
-### Note
-
-`request.user` call will lead to
-`AssertionError: AuthenticationMiddleware must be installed to access request.user`.
-Our authentication middleware is ignoring the excluded path
-and `request.user` is not being set.
-The exception raised by `starlette`.
-You do not need to use `AuthenticationMiddleware` except you need to.
diff --git a/docs/tutorial/sqladmin.md b/docs/tutorial/sqladmin.md
index fabf9a8..42d3378 100644
--- a/docs/tutorial/sqladmin.md
+++ b/docs/tutorial/sqladmin.md
@@ -291,9 +291,8 @@ middleware = [
AuthenticationMiddleware,
backend=SessionAuthBackend(login_manager),
login_manager=login_manager,
- secret_key=SECRET_KEY,
excluded_dirs=['/static']
- )
+ ),
)
```
diff --git a/docs/usage.md b/docs/usage.md
new file mode 100644
index 0000000..92bec34
--- /dev/null
+++ b/docs/usage.md
@@ -0,0 +1,118 @@
+## Login Manager
+
+First of all, you need to create a `LoginManager` instance.
+The login manager manage the `Starlette-Login` behaviour of your `Starllate` instance.
+
+```python
+from starlette_login.login_manager import LoginManager
+
+login_manager = LoginManager(redirect_to='/login_endpoint', secret_key=SECRET_KEY)
+```
+
+Then you will need to provide a __user loader__ callback function.
+
+```python
+from starlette.requests import Request
+
+from model import User
+
+async def get_user_by_id(request: Request, user_id: int):
+ # return a sub class of `mixin.UserMixin` instance
+ db = request.state.db
+ user = await User.get_by_id(db, user_id)
+ return user
+
+
+login_manager.set_user_loader(get_user_by_id)
+```
+
+## User Class
+
+User class mush inherit `UserMixin` class.
+
+```python
+from starlette_login.mixins import UserMixin
+
+class User(UserMixin):
+ user_id: int
+ name: str
+
+ def identity(self) -> int:
+ return self.user_id
+
+ def display_name(self):
+ return self.name
+```
+
+## Starlette Application and Middleware
+
+Upon creation of `Starlette` instance, we add `SessionMiddleware` and `AuthenticationMiddleware`.
+
+`SessionMiddleware` is required to manage `http` and `websocket` session.
+
+```python
+from starlette.applications import Starlette
+from starlette.middleware import Middleware
+from starlette.middleware.sessions import SessionMiddleware
+from starlette_login.backends import SessionAuthBackend
+from starlette_login.middleware import AuthenticationMiddleware
+
+app = Starlette(
+ middleware=[
+ Middleware(SessionMiddleware, secret_key=SECRET_KEY),
+ Middleware(
+ AuthenticationMiddleware,
+ backend=SessionAuthBackend(login_manager),
+ login_manager=login_manager,
+ login_route='login',
+ )
+ ],
+ ...
+)
+```
+
+Then you need to add the `login manager` to `Starlette` instance `state`.
+
+```python
+app.state.login_manager = login_manager
+```
+
+## Login and Logout
+
+Now that the `Starlette` application instance is ready to use,
+you will need to create a `login` and `logout` route to manage user authentication.
+
+See `routes.py` on [Basic](index.md) page for `login` and `logout` route example.
+
+
+## Decorator
+
+`Starlette-Login` Decorator helps to prevent non-authorized user to access certain route.
+There are 3 available __decorator__:
+
+- `login_required`: only authenticated user can access the page
+- `fresh_login_required`: only _newly logged-in_ user can access the page
+- `ws_login_required`: websocket route version of `login_required`
+
+__Usage__
+
+```python
+from starlette.requests import Request
+from starlette.responses import PlainTextResponse
+from starlette.websockets import WebSocket
+from starlette_login.decorator import login_required, ws_login_required
+
+
+@login_required
+async def protected_page(request: Request):
+ return PlainTextResponse(f'You are logged in as {request.user.username}')
+
+
+@ws_login_required
+async def ws_endpoint(websocket: WebSocket):
+ await websocket.accept()
+ await websocket.send_text("authenticated")
+ await websocket.close()
+```
+
+See [tests/views.py](https://github.com/jockerz/Starlette-Login/blob/main/tests/views.py) for more decorated routes example.
diff --git a/mkdocs.yml b/mkdocs.yml
index 20fd5a7..783612e 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -29,6 +29,7 @@ edit_uri: ""
nav:
- Basic: index.md
+ - Usage: usage.md
- Customization:
- Login Manager: custom/login-manager.md
- Configuration: custom/configuration.md
diff --git a/starlette_login/__init__.py b/starlette_login/__init__.py
index bbab024..1276d02 100644
--- a/starlette_login/__init__.py
+++ b/starlette_login/__init__.py
@@ -1 +1 @@
-__version__ = "0.1.4"
+__version__ = "0.1.5"
diff --git a/starlette_login/backends.py b/starlette_login/backends.py
index 02ae0da..79dc0ba 100644
--- a/starlette_login/backends.py
+++ b/starlette_login/backends.py
@@ -5,6 +5,7 @@
from .login_manager import LoginManager
from .mixins import UserMixin
+from .utils import create_identifier
class BaseAuthenticationBackend:
@@ -22,28 +23,28 @@ async def authenticate(
self, conn: HTTPConnection
) -> t.Optional[UserMixin]:
# Load user from session
- session_key = self.login_manager.config.SESSION_NAME_KEY
- user_id = conn.session.get(session_key)
-
- remember_cookie = self.login_manager.config.REMEMBER_COOKIE_NAME
- session_fresh = self.login_manager.config.SESSION_NAME_FRESH
-
- # Using Strong protection
- if self.login_manager.protection_is_strong():
- for key in self.login_manager.config.session_keys:
- try:
- conn.session.pop(key)
- except KeyError:
- pass
- conn.session[remember_cookie] = "clear"
- else:
+ config = self.login_manager.config
+ remember_cookie = config.REMEMBER_COOKIE_NAME
+ session_fresh = config.SESSION_NAME_FRESH
+
+ session = conn.session.get(config.SESSION_NAME_ID)
+ identifier = create_identifier(conn)
+
+ if identifier != session:
+ if self.login_manager.protection_is_strong():
+ # Strong protection
+ for key in config.session_keys:
+ conn.session.pop(key, None)
+ # conn.session[remember_cookie] = "clear"
+ # else:
conn.session[session_fresh] = False
+ user_id = conn.session.get(config.SESSION_NAME_KEY)
if (
user_id is None
- and conn.session.get(remember_cookie, "clear") != "clear"
+ and conn.session.get(remember_cookie) != "clear"
):
- cookie = conn.cookies.get(self.login_manager.config.COOKIE_NAME)
+ cookie = conn.cookies.get(config.COOKIE_NAME)
if cookie:
user_id = self.login_manager.get_cookie(cookie)
user_id = int(user_id)
diff --git a/starlette_login/login_manager.py b/starlette_login/login_manager.py
index cb8bd62..f3f2038 100644
--- a/starlette_login/login_manager.py
+++ b/starlette_login/login_manager.py
@@ -26,7 +26,6 @@ class Config:
SESSION_NAME_ID: str = "_id"
SESSION_NAME_KEY: str = "_user_id"
SESSION_NAME_NEXT: str = "next"
-
REMEMBER_COOKIE_NAME: str = "_remember"
REMEMBER_SECONDS_NAME: str = "_remember_seconds"
EXEMPT_METHODS: t.Tuple[str] = ("OPTIONS",)
@@ -39,6 +38,7 @@ class Config:
COOKIE_PATH: str = "/"
COOKIE_SECURE: bool = False
COOKIE_HTTPONLY: bool = True
+ # Not supported on python 3.6 and 3.7
# COOKIE_SAMESITE: t.Optional[t.Literal["lax", "strict", "none"]] = None
COOKIE_SAMESITE: t.Optional[str] = None
COOKIE_DURATION: timedelta = timedelta(days=365)
diff --git a/starlette_login/middleware.py b/starlette_login/middleware.py
index 86f5c00..0cd068f 100644
--- a/starlette_login/middleware.py
+++ b/starlette_login/middleware.py
@@ -1,7 +1,7 @@
import typing as t
from starlette.requests import HTTPConnection
-from starlette.types import ASGIApp, Receive, Scope, Send
+from starlette.types import ASGIApp, Receive, Scope, Send, Message
from .backends import BaseAuthenticationBackend
from .login_manager import LoginManager
@@ -13,16 +13,15 @@ def __init__(
app: ASGIApp,
backend: BaseAuthenticationBackend,
login_manager: LoginManager,
- secret_key: str,
login_route: str = None,
excluded_dirs: t.List[str] = None,
):
self.app = app
self.backend = backend
- self.login_route = login_route
- self.secret_key = secret_key
- self.login_manager = login_manager
self.excluded_dirs = excluded_dirs or []
+ self.login_manager = login_manager
+ self.login_route = login_route
+ self.secret_key = login_manager.secret_key
async def __call__(
self, scope: Scope, receive: Receive, send: Send
@@ -38,13 +37,15 @@ async def __call__(
return
conn = HTTPConnection(scope=scope, receive=receive)
+
user = await self.backend.authenticate(conn)
if not user or user.is_authenticated is False:
conn.scope["user"] = self.login_manager.anonymous_user_cls()
else:
conn.scope["user"] = user
- async def custom_send(message):
+ async def custom_send(message: Message):
+ user = conn.scope["user"]
if user and user.is_authenticated:
operation = conn.session.get(
self.login_manager.config.REMEMBER_COOKIE_NAME
diff --git a/starlette_login/utils.py b/starlette_login/utils.py
index e34a90e..6fdc933 100644
--- a/starlette_login/utils.py
+++ b/starlette_login/utils.py
@@ -17,7 +17,7 @@ async def login_user(
remember: bool = False,
duration: timedelta = None,
fresh: bool = True,
-) -> bool:
+) -> None:
assert request.scope.get("app") is not None, "Invalid Starlette app"
login_manager = getattr(request.app.state, "login_manager", None)
assert login_manager is not None, LOGIN_MANAGER_ERROR
@@ -25,19 +25,19 @@ async def login_user(
user.identity is not None
), "user identity implementation is required"
- request.session[login_manager.config.SESSION_NAME_KEY] = user.identity
- request.session[login_manager.config.SESSION_NAME_FRESH] = fresh
- request.session[login_manager.config.SESSION_NAME_ID] = create_identifier(
- request
- )
+ config = login_manager.config
+
+ request.session[config.SESSION_NAME_KEY] = user.identity
+ request.session[config.SESSION_NAME_FRESH] = fresh
+ request.session[config.SESSION_NAME_ID] = create_identifier(request)
if remember:
- request.session[login_manager.config.REMEMBER_COOKIE_NAME] = "set"
+ request.session[config.REMEMBER_COOKIE_NAME] = "set"
if duration is not None:
request.session[
- login_manager.config.REMEMBER_SECONDS_NAME
+ config.REMEMBER_SECONDS_NAME
] = duration.total_seconds()
+
request.scope["user"] = user
- return True
async def logout_user(request: Request) -> None:
@@ -45,10 +45,12 @@ async def logout_user(request: Request) -> None:
login_manager = getattr(request.app.state, "login_manager", None)
assert login_manager is not None, LOGIN_MANAGER_ERROR
- session_key = login_manager.config.SESSION_NAME_KEY
- session_fresh = login_manager.config.SESSION_NAME_FRESH
- session_id = login_manager.config.SESSION_NAME_ID
- remember_cookie = login_manager.config.REMEMBER_COOKIE_NAME
+ config = login_manager.config
+ session_key = config.SESSION_NAME_KEY
+ session_fresh = config.SESSION_NAME_FRESH
+ session_id = config.SESSION_NAME_ID
+ remember_cookie = config.REMEMBER_COOKIE_NAME
+ remember_seconds = config.REMEMBER_SECONDS_NAME
if session_key in request.session:
request.session.pop(session_key)
@@ -61,7 +63,6 @@ async def logout_user(request: Request) -> None:
if remember_cookie in request.cookies:
request.session[remember_cookie] = "clear"
- remember_seconds = login_manager.config.REMEMBER_SECONDS_NAME
if remember_seconds in request.session:
request.session.pop(remember_seconds)
diff --git a/tests/conftest.py b/tests/conftest.py
index c40b222..d62c4ba 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -54,7 +54,6 @@ async def app():
backend=SessionAuthBackend(login_manager),
login_manager=login_manager,
login_route="login",
- secret_key="secret",
excluded_dirs=["/excluded"],
),
]
@@ -78,7 +77,6 @@ async def secure_app():
backend=SessionAuthBackend(x_login_manager),
login_manager=x_login_manager,
login_route="login",
- secret_key="secret",
excluded_dirs=["/excluded"],
),
]
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 0530042..f14205e 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -56,7 +56,40 @@ async def test_remember_me(self, test_client):
assert resp.status_code == 200
assert data["session"]["_remember"] == "set"
- async def test_remember_me_strong_protection(self, secure_test_client):
+
+@pytest.mark.asyncio
+class TestStrongProtection:
+ async def test_regular(self, secure_test_client):
+ _ = secure_test_client.post(
+ "/login",
+ data={
+ "username": "user1",
+ "password": "password",
+ },
+ )
+
+ resp = secure_test_client.get("/request_data")
+ assert resp.status_code == 200
+ assert resp.json()
+
+ async def test_identifier_changed(self, secure_test_client):
+ _ = secure_test_client.post(
+ "/login",
+ data={
+ "username": "user1",
+ "password": "password",
+ },
+ )
+
+ secure_test_client.headers['user-agent'] = 'changed'
+ resp = secure_test_client.get("/request_data")
+
+ assert '' in resp.text
+
+
+@pytest.mark.asyncio
+class TestStrongProtectionRememberMe:
+ async def test_regular(self, secure_test_client):
_ = secure_test_client.post(
"/login",
data={
@@ -68,5 +101,26 @@ async def test_remember_me_strong_protection(self, secure_test_client):
resp = secure_test_client.get("/request_data")
data = resp.json()
+
+ assert resp.status_code == 200
+ assert data["session"]["_remember"] != "clear"
+
+ async def test_identifier_changed(self, secure_test_client):
+ resp = secure_test_client.post(
+ "/login",
+ data={
+ "username": "user1",
+ "password": "password",
+ "remember": True,
+ },
+ )
+
+ secure_test_client.headers['user-agent'] = 'changed'
+
+ resp = secure_test_client.get("/request_data")
+ assert resp.status_code == 200
+ assert '' not in resp.text
+
+ resp = secure_test_client.get("/request_data")
assert resp.status_code == 200
- assert data["session"]["_remember"] == "clear"
+ assert '' not in resp.text