From 94dc9f894d0a73ef3b754fdf78171a67d7243dd7 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Mon, 18 Sep 2023 16:05:15 -0700 Subject: [PATCH] Initial commit of `local_auth` example (#143) --- local_auth/.gitignore | 4 + local_auth/README.md | 91 +++++++++++++++ local_auth/alembic.ini | 110 +++++++++++++++++ local_auth/alembic/README | 1 + local_auth/alembic/env.py | 78 +++++++++++++ local_auth/alembic/script.py.mako | 26 +++++ local_auth/alembic/versions/e7f2b1379e19_.py | 50 ++++++++ local_auth/assets/favicon.ico | Bin 0 -> 15086 bytes local_auth/local_auth/__init__.py | 0 local_auth/local_auth/auth_session.py | 19 +++ local_auth/local_auth/base_state.py | 94 +++++++++++++++ local_auth/local_auth/local_auth.py | 48 ++++++++ local_auth/local_auth/login.py | 117 +++++++++++++++++++ local_auth/local_auth/registration.py | 103 ++++++++++++++++ local_auth/local_auth/user.py | 43 +++++++ local_auth/requirements.txt | 3 + local_auth/rxconfig.py | 8 ++ 17 files changed, 795 insertions(+) create mode 100644 local_auth/.gitignore create mode 100644 local_auth/README.md create mode 100644 local_auth/alembic.ini create mode 100644 local_auth/alembic/README create mode 100644 local_auth/alembic/env.py create mode 100644 local_auth/alembic/script.py.mako create mode 100644 local_auth/alembic/versions/e7f2b1379e19_.py create mode 100644 local_auth/assets/favicon.ico create mode 100644 local_auth/local_auth/__init__.py create mode 100644 local_auth/local_auth/auth_session.py create mode 100644 local_auth/local_auth/base_state.py create mode 100644 local_auth/local_auth/local_auth.py create mode 100644 local_auth/local_auth/login.py create mode 100644 local_auth/local_auth/registration.py create mode 100644 local_auth/local_auth/user.py create mode 100644 local_auth/requirements.txt create mode 100644 local_auth/rxconfig.py 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 0000000000000000000000000000000000000000..609f6abcbeb0fec5157b54a36d7b2aeb759f9e1b GIT binary patch literal 15086 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 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