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