diff --git a/local_auth/.gitignore b/local_auth/.gitignore new file mode 100644 index 00000000..eab0d4b0 --- /dev/null +++ b/local_auth/.gitignore @@ -0,0 +1,4 @@ +*.db +*.py[cod] +.web +__pycache__/ \ No newline at end of file diff --git a/local_auth/README.md b/local_auth/README.md new file mode 100644 index 00000000..3cb7dc2e --- /dev/null +++ b/local_auth/README.md @@ -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**. diff --git a/local_auth/alembic.ini b/local_auth/alembic.ini new file mode 100644 index 00000000..d9f94e0c --- /dev/null +++ b/local_auth/alembic.ini @@ -0,0 +1,110 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/local_auth/alembic/README b/local_auth/alembic/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/local_auth/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/local_auth/alembic/env.py b/local_auth/alembic/env.py new file mode 100644 index 00000000..36112a3c --- /dev/null +++ b/local_auth/alembic/env.py @@ -0,0 +1,78 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/local_auth/alembic/script.py.mako b/local_auth/alembic/script.py.mako new file mode 100644 index 00000000..fbc4b07d --- /dev/null +++ b/local_auth/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/local_auth/alembic/versions/e7f2b1379e19_.py b/local_auth/alembic/versions/e7f2b1379e19_.py new file mode 100644 index 00000000..3c144c01 --- /dev/null +++ b/local_auth/alembic/versions/e7f2b1379e19_.py @@ -0,0 +1,50 @@ +"""empty message + +Revision ID: e7f2b1379e19 +Revises: +Create Date: 2023-09-06 23:26:13.577395 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = 'e7f2b1379e19' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('authsession', + sa.Column('expiration', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('session_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_authsession_session_id'), 'authsession', ['session_id'], unique=True) + op.create_index(op.f('ix_authsession_user_id'), 'authsession', ['user_id'], unique=False) + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_user_username'), table_name='user') + op.drop_table('user') + op.drop_index(op.f('ix_authsession_user_id'), table_name='authsession') + op.drop_index(op.f('ix_authsession_session_id'), table_name='authsession') + op.drop_table('authsession') + # ### end Alembic commands ### diff --git a/local_auth/assets/favicon.ico b/local_auth/assets/favicon.ico new file mode 100644 index 00000000..609f6abc Binary files /dev/null and b/local_auth/assets/favicon.ico differ diff --git a/local_auth/local_auth/__init__.py b/local_auth/local_auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/local_auth/local_auth/auth_session.py b/local_auth/local_auth/auth_session.py new file mode 100644 index 00000000..3af50d3d --- /dev/null +++ b/local_auth/local_auth/auth_session.py @@ -0,0 +1,19 @@ +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, + ) diff --git a/local_auth/local_auth/base_state.py b/local_auth/local_auth/base_state.py new file mode 100644 index 00000000..437419e4 --- /dev/null +++ b/local_auth/local_auth/base_state.py @@ -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( + AuthSession.select.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.get_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() diff --git a/local_auth/local_auth/local_auth.py b/local_auth/local_auth/local_auth.py new file mode 100644 index 00000000..7ee952fa --- /dev/null +++ b/local_auth/local_auth/local_auth.py @@ -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="1.5em", + padding_top="10%", + ), + ) + + +@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() +app.add_page(index) +app.add_page(protected) +app.compile() diff --git a/local_auth/local_auth/login.py b/local_auth/local_auth/login.py new file mode 100644 index 00000000..16a7f5b8 --- /dev/null +++ b/local_auth/local_auth/login.py @@ -0,0 +1,117 @@ +"""Login page and authentication logic.""" +import reflex as rx + +from .base_state import State +from .user import User + + +LOGIN_ROUTE = "/login" +REGISTER_ROUTE = "/register" + + +class LoginState(State): + """Handle login form submission and redirect to proper routes after authentication.""" + + error_message: str = "" + redirect_to: str = "" + + def on_submit(self, form_data) -> rx.event.EventSpec: + """Handle login form on_submit. + + Args: + form_data: A dict of form fields and values. + """ + self.error_message = "" + username = form_data["username"] + password = form_data["password"] + with rx.session() as session: + user = session.exec( + User.select.where(User.username == username) + ).one_or_none() + if user is not None and not user.enabled: + self.error_message = "This account is disabled." + return rx.set_value("password", "") + if user is None or not user.verify(password): + self.error_message = "There was a problem logging in, please try again." + return rx.set_value("password", "") + if ( + user is not None + and user.id is not None + and user.enabled + and user.verify(password) + ): + # mark the user as logged in + self._login(user.id) + self.error_message = "" + return LoginState.redir() # type: ignore + + def redir(self) -> rx.event.EventSpec | None: + """Redirect to the redirect_to route if logged in, or to the login page if not.""" + if not self.is_hydrated: + # wait until after hydration to ensure auth_token is known + return LoginState.redir() # type: ignore + page = self.get_current_page() + if not self.is_authenticated and page != LOGIN_ROUTE: + self.redirect_to = page + return rx.redirect(LOGIN_ROUTE) + elif page == LOGIN_ROUTE: + return rx.redirect(self.redirect_to or "/") + + +@rx.page(route=LOGIN_ROUTE) +def login_page() -> rx.Component: + """Render the login page. + + Returns: + A reflex component. + """ + login_form = rx.form( + rx.input(placeholder="username", id="username"), + rx.password(placeholder="password", id="password"), + rx.button("Login", type_="submit"), + width="80vw", + on_submit=LoginState.on_submit, + ) + + return rx.fragment( + rx.cond( + LoginState.is_hydrated, # type: ignore + rx.vstack( + rx.cond( # conditionally show error messages + LoginState.error_message != "", + rx.text(LoginState.error_message), + ), + login_form, + rx.link("Register", href=REGISTER_ROUTE), + padding_top="10vh", + ), + ) + ) + + +def require_login(page: rx.app.ComponentCallable) -> rx.app.ComponentCallable: + """Decorator to require authentication before rendering a page. + + If the user is not authenticated, then redirect to the login page. + + Args: + page: The page to wrap. + + Returns: + The wrapped page component. + """ + + def protected_page(): + return rx.fragment( + rx.cond( + State.is_hydrated & State.is_authenticated, # type: ignore + page(), + rx.center( + # When this spinner mounts, it will redirect to the login page + rx.spinner(on_mount=LoginState.redir), + ), + ) + ) + + protected_page.__name__ = page.__name__ + return protected_page diff --git a/local_auth/local_auth/registration.py b/local_auth/local_auth/registration.py new file mode 100644 index 00000000..23cb3bf4 --- /dev/null +++ b/local_auth/local_auth/registration.py @@ -0,0 +1,103 @@ +"""New user registration form and validation logic.""" +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator + +import reflex as rx + +from .base_state import State +from .login import LOGIN_ROUTE, REGISTER_ROUTE +from .user import User + + +class RegistrationState(State): + """Handle registration form submission and redirect to login page after registration.""" + + success: bool = False + error_message: str = "" + + async def handle_registration( + self, form_data + ) -> AsyncGenerator[rx.event.EventSpec | list[rx.event.EventSpec] | None, None]: + """Handle registration form on_submit. + + Set error_message appropriately based on validation results. + + Args: + form_data: A dict of form fields and values. + """ + with rx.session() as session: + username = form_data["username"] + if not username: + self.error_message = "Username cannot be empty" + yield rx.set_focus("username") + return + existing_user = session.exec( + User.select.where(User.username == username) + ).one_or_none() + if existing_user is not None: + self.error_message = ( + f"Username {username} is already registered. Try a different name" + ) + yield [rx.set_value("username", ""), rx.set_focus("username")] + return + password = form_data["password"] + if not password: + self.error_message = "Password cannot be empty" + yield rx.set_focus("password") + return + if password != form_data["confirm_password"]: + self.error_message = "Passwords do not match" + yield [ + rx.set_value("confirm_password", ""), + rx.set_focus("confirm_password"), + ] + return + # Create the new user and add it to the database. + new_user = User() # type: ignore + new_user.username = username + new_user.password_hash = User.hash_password(password) + new_user.enabled = True + session.add(new_user) + session.commit() + # Set success and redirect to login page after a brief delay. + self.error_message = "" + self.success = True + yield + await asyncio.sleep(0.5) + yield [rx.redirect(LOGIN_ROUTE), RegistrationState.set_success(False)] + + +@rx.page(route=REGISTER_ROUTE) +def registration_page() -> rx.Component: + """Render the registration page. + + Returns: + A reflex component. + """ + register_form = rx.form( + rx.input(placeholder="username", id="username"), + rx.password(placeholder="password", id="password"), + rx.password(placeholder="confirm", id="confirm_password"), + rx.button("Register", type_="submit"), + width="80vw", + on_submit=RegistrationState.handle_registration, + ) + return rx.fragment( + rx.cond( + RegistrationState.success, + rx.vstack( + rx.text("Registration successful!"), + rx.spinner(), + ), + rx.vstack( + rx.cond( # conditionally show error messages + RegistrationState.error_message != "", + rx.text(RegistrationState.error_message), + ), + register_form, + padding_top="10vh", + ), + ) + ) diff --git a/local_auth/local_auth/user.py b/local_auth/local_auth/user.py new file mode 100644 index 00000000..bc58fe4a --- /dev/null +++ b/local_auth/local_auth/user.py @@ -0,0 +1,43 @@ +from passlib.context import CryptContext +from sqlmodel import Field + +import reflex as rx + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +class User( + rx.Model, + table=True, # type: ignore +): + """A local User model with bcrypt password hashing.""" + + username: str = Field(unique=True, nullable=False, index=True) + password_hash: str = Field(nullable=False) + enabled: bool = False + + @staticmethod + def hash_password(secret: str) -> str: + """Hash the secret using bcrypt. + + Args: + secret: The password to hash. + + Returns: + The hashed password. + """ + return pwd_context.hash(secret) + + def verify(self, secret: str) -> bool: + """Validate the user's password. + + Args: + secret: The password to check. + + Returns: + True if the hashed secret matches this user's password_hash. + """ + return pwd_context.verify( + secret, + self.password_hash, + ) diff --git a/local_auth/requirements.txt b/local_auth/requirements.txt new file mode 100644 index 00000000..86a89dac --- /dev/null +++ b/local_auth/requirements.txt @@ -0,0 +1,3 @@ +reflex>=0.2.7 +passlib +bcrypt diff --git a/local_auth/rxconfig.py b/local_auth/rxconfig.py new file mode 100644 index 00000000..12e5cf8d --- /dev/null +++ b/local_auth/rxconfig.py @@ -0,0 +1,8 @@ +import reflex as rx + +class LocalauthConfig(rx.Config): + pass + +config = LocalauthConfig( + app_name="local_auth", +) \ No newline at end of file