Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Local auth app to radix namespace #207

Merged
merged 3 commits into from
Feb 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions local_auth/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
*.db
*.py[cod]
.web
__pycache__/
91 changes: 91 additions & 0 deletions local_auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Local Authentication Example

See example app code: [`local_auth.py`](./local_auth/local_auth.py)

## Models

This example makes use of two models, [`User`](./local_auth/user.py) and
[`AuthSession`](./local_auth/auth_session.py), which store user login
information and authenticated user sessions respectively.

User passwords are hashed in the database with
[`passlib`](https://pypi.org/project/passlib/) using
[`bcrypt`](https://pypi.org/project/bcrypt/) algorithm. However, during
registration and login, the unhashed password is sent over the websocket, so
**it is critical to use TLS to protect the websocket connection**.

## States

The base [`State`](./local_auth/base_state.py) class stores the `auth_token` as
a `LocalStorage` var, allowing logins to persist across browser tabs and
sessions.

It also exposes `authenticated_user` as a cached computed var, which
looks up the `auth_token` in the `AuthSession` table and returns a matching
`User` if any exists. The `is_authenticated` cached var is a convenience for
determining whether the `auth_token` is associated with a valid user.

The public event handler, `do_logout`, may be called from the frontend and will
destroy the `AuthSession` associated with the current `auth_token`.

The private event handler, `_login` is only callable from the backend, and
establishes an `AuthSession` for the given `user_id`. It assumes that the
validity of the user credential has already been established, which is why it is
a private handler.

### Registration

The [`RegistrationState`](./local_auth/registration.py) class handles the
submission of the register form, checking for input validity and ultimately
creating a new user in the database.

After successful registration, the event handler redirects back to the login
page after a brief delay.

### Login

The [`LoginState`](./local_auth/login.py) class handles the submission of the
login form, checking the user password, and ultimately redirecting back to the
last page that requested login (or the index page).

The `LoginState.redir` event handler is a bit special because it behaves
differently depending on the page it is called from.

* If `redir` is called from any page except `/login` and there is no
authenticated user, it saves the current page route as `redirect_to` and
forces a redirect to `/login`.
* If `redir` is called from `/login` and the there is an authenticated
user, it will redirect to the route saved as `redirect_to` (or `/`)

## Forms and Flow

### `@require_login`

The `login.require_login` decorator is intended to be used on pages that require
authentication to be viewed. It uses `rx.cond` to conditionally render either
the wrapped page, or some loading spinners as placeholders. Because one of the
spinners specifies `LoginState.redir` as the event handler for its `on_mount`
trigger, it will handle redirection to the login page if needed.

### Login Form

The login form triggers `LoginState.on_submit` when submitted, and this function
is responsible for looking up the user and validating the password against the
database. Once the user is authenticated, `State._login` is called to create the
`AuthSession` associating the `user_id` with the `auth_token` stored in the
browser's `LocalStorage` area.

Finally `on_submit` chains back into `LoginState.redir` to handle redirection
back to the page that requested the login (stored as `LoginState.redirect_to`).

### Protect the State

Keep in mind that **all pages in a reflex app are publicly accessible**! The
`redir` mechanism is designed to get users to and from the login page, it is NOT
designed to protect private data.

All private data needs to originate from computed vars or event handlers setting
vars after explicitly checking `State.authenticated_user` on the backend.
Static data passed to components, even on protected pages, can be retrieved
without logging in. It cannot be stressed enough that **private data MUST come
from the state**.
Binary file added local_auth/assets/favicon.ico
Binary file not shown.
Empty file.
18 changes: 18 additions & 0 deletions local_auth/local_auth/auth_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import datetime

from sqlmodel import Column, DateTime, Field, func

import reflex as rx


class AuthSession(
rx.Model,
table=True, # type: ignore
):
"""Correlate a session_id with an arbitrary user_id."""

user_id: int = Field(index=True, nullable=False)
session_id: str = Field(unique=True, index=True, nullable=False)
expiration: datetime.datetime = Field(
sa_column=Column(DateTime(timezone=True), server_default=func.now(), nullable=False),
)
94 changes: 94 additions & 0 deletions local_auth/local_auth/base_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""
Top-level State for the App.

Authentication data is stored in the base State class so that all substates can
access it for verifying access to event handlers and computed vars.
"""
import datetime

from sqlmodel import select

import reflex as rx

from .auth_session import AuthSession
from .user import User


AUTH_TOKEN_LOCAL_STORAGE_KEY = "_auth_token"
DEFAULT_AUTH_SESSION_EXPIRATION_DELTA = datetime.timedelta(days=7)


class State(rx.State):
# The auth_token is stored in local storage to persist across tab and browser sessions.
auth_token: str = rx.LocalStorage(name=AUTH_TOKEN_LOCAL_STORAGE_KEY)

@rx.cached_var
def authenticated_user(self) -> User:
"""The currently authenticated user, or a dummy user if not authenticated.

Returns:
A User instance with id=-1 if not authenticated, or the User instance
corresponding to the currently authenticated user.
"""
with rx.session() as session:
result = session.exec(
select(User, AuthSession).where(
AuthSession.session_id == self.auth_token,
AuthSession.expiration
>= datetime.datetime.now(datetime.timezone.utc),
User.id == AuthSession.user_id,
),
).first()
if result:
user, session = result
return user
return User(id=-1) # type: ignore

@rx.cached_var
def is_authenticated(self) -> bool:
"""Whether the current user is authenticated.

Returns:
True if the authenticated user has a positive user ID, False otherwise.
"""
return self.authenticated_user.id >= 0

def do_logout(self) -> None:
"""Destroy AuthSessions associated with the auth_token."""
with rx.session() as session:
for auth_session in session.exec(
select(AuthSession).where(AuthSession.session_id == self.auth_token)
).all():
session.delete(auth_session)
session.commit()
self.auth_token = self.auth_token

def _login(
self,
user_id: int,
expiration_delta: datetime.timedelta = DEFAULT_AUTH_SESSION_EXPIRATION_DELTA,
) -> None:
"""Create an AuthSession for the given user_id.

If the auth_token is already associated with an AuthSession, it will be
logged out first.

Args:
user_id: The user ID to associate with the AuthSession.
expiration_delta: The amount of time before the AuthSession expires.
"""
if self.is_authenticated:
self.do_logout()
if user_id < 0:
return
self.auth_token = self.auth_token or self.router.session.client_token
with rx.session() as session:
session.add(
AuthSession( # type: ignore
user_id=user_id,
session_id=self.auth_token,
expiration=datetime.datetime.now(datetime.timezone.utc)
+ expiration_delta,
)
)
session.commit()
48 changes: 48 additions & 0 deletions local_auth/local_auth/local_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Main app module to demo local authentication."""
import reflex as rx

from .base_state import State
from .login import require_login
from .registration import registration_page as registration_page


def index() -> rx.Component:
"""Render the index page.

Returns:
A reflex component.
"""
return rx.fragment(
rx.color_mode.button(rx.color_mode.icon(), float="right"),
rx.vstack(
rx.heading("Welcome to my homepage!", font_size="2em"),
rx.link("Protected Page", href="/protected"),
spacing="2",
padding_top="10%",
align_items="center"
),
)


@require_login
def protected() -> rx.Component:
"""Render a protected page.

The `require_login` decorator will redirect to the login page if the user is
not authenticated.

Returns:
A reflex component.
"""
return rx.vstack(
rx.heading(
"Protected Page for ", State.authenticated_user.username, font_size="2em"
),
rx.link("Home", href="/"),
rx.link("Logout", href="/", on_click=State.do_logout),
)


app = rx.App(theme=rx.theme(has_background=True, accent_color="orange"))
app.add_page(index)
app.add_page(protected)
Loading