├── .github └── workflows │ ├── pre-commit.yaml │ └── publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── README.md ├── custom_components └── reflex_local_auth │ ├── __init__.py │ ├── auth_session.py │ ├── local_auth.py │ ├── login.py │ ├── pages │ ├── __init__.py │ ├── components.py │ ├── login.py │ └── registration.py │ ├── registration.py │ ├── routes.py │ └── user.py ├── local_auth_demo ├── .gitignore ├── alembic.ini ├── alembic │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions │ │ ├── 05267fabef38_.py │ │ └── cb01e050df85_.py ├── assets │ └── favicon.ico ├── local_auth_demo │ ├── __init__.py │ ├── custom_user_info.py │ └── local_auth_demo.py ├── requirements.txt └── rxconfig.py └── pyproject.toml /.github/workflows/pre-commit.yaml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | pull_request: 5 | branches: ["main"] 6 | push: 7 | branches: ["main"] 8 | 9 | jobs: 10 | pre-commit: 11 | timeout-minutes: 30 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: # Fetch tags for setuptools-scm 16 | fetch-depth: 0 17 | - uses: actions/setup-python@v2 18 | with: 19 | python-version: "3.12" 20 | - run: pip install pre-commit pyright . 21 | - run: find . -name requirements.txt | sed -e 's/^/-r/' | xargs pip install 22 | - run: pre-commit run --all-files 23 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Component and Deploy Demo App 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | env: 8 | EXTRA_ARGS: "--hostname ${{ vars.HOSTNAME }}" 9 | 10 | jobs: 11 | publish: 12 | name: Publish Component to PyPI 13 | runs-on: ubuntu-latest 14 | env: 15 | HAS_PYPI_TOKEN: ${{ secrets.PYPI_TOKEN != '' }} 16 | steps: 17 | - uses: actions/checkout@master 18 | - name: Set up Python 3.12 19 | uses: actions/setup-python@v3 20 | with: 21 | python-version: "3.12" 22 | - name: Install package 23 | run: pip install . 'reflex>=0.7.0a3' 24 | - name: Publish to PyPI 25 | if: ${{ env.HAS_PYPI_TOKEN == 'true' }} 26 | run: reflex component publish -t ${{ secrets.PYPI_TOKEN }} --no-share --no-validate-project-info 27 | deploy: 28 | name: Deploy Demo App to Reflex Cloud 29 | runs-on: ubuntu-latest 30 | needs: publish 31 | env: 32 | HAS_REFLEX_AUTH_TOKEN: ${{ secrets.REFLEX_AUTH_TOKEN != '' }} 33 | steps: 34 | - uses: actions/checkout@master 35 | - name: Set up Python 3.12 36 | uses: actions/setup-python@v3 37 | with: 38 | python-version: "3.12" 39 | - name: Set tagged version for deploy 40 | run: sed -E 's/^${{ vars.COMPONENT_NAME }}([ >=].*)?$/${{ vars.COMPONENT_NAME }}==${{ github.event.release.tag_name }}/' -i ${{ vars.APP_DIRECTORY }}/requirements.txt 41 | - name: Allow pre-release version of reflex 42 | run: sed -E 's/(reflex[ >=][^a]*$)/\1a/' -i ${{ vars.APP_DIRECTORY }}/requirements.txt 43 | - name: Wait for package to become available 44 | uses: masenf/wait-for-pypi-action@main 45 | with: 46 | package_name: ${{ vars.COMPONENT_NAME}} 47 | package_version: ${{ github.event.release.tag_name }} 48 | timeout: "120" 49 | delay_between_requests: "5" 50 | - name: Deploy to ReflexCloud 51 | uses: reflex-dev/reflex-deploy-action@v2 52 | if: ${{ env.HAS_REFLEX_AUTH_TOKEN == 'true' }} 53 | with: 54 | auth_token: ${{ secrets.REFLEX_AUTH_TOKEN }} 55 | project_id: ${{ secrets.REFLEX_PROJECT_ID }} 56 | app_directory: ${{ vars.APP_DIRECTORY }} 57 | extra_args: ${{ env.EXTRA_ARGS }} 58 | dry_run: ${{ vars.DRY_RUN }} 59 | skip_checkout: "true" 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | *.py[cod] 3 | .web 4 | __pycache__/ 5 | *.egg-info 6 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | 3 | repos: 4 | - repo: https://github.com/charliermarsh/ruff-pre-commit 5 | rev: v0.9.3 6 | hooks: 7 | - id: ruff-format 8 | - id: ruff 9 | args: ["--fix", "--exit-non-zero-on-fix"] 10 | 11 | - repo: https://github.com/codespell-project/codespell 12 | rev: v2.3.0 13 | hooks: 14 | - id: codespell 15 | 16 | - repo: https://github.com/RobertCraigie/pyright-python 17 | rev: v1.1.392 18 | hooks: 19 | - id: pyright 20 | args: [] 21 | language: system -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # local-auth 2 | 3 | Easy access to local authentication in your [Reflex](https://reflex.dev) app. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | pip install reflex-local-auth 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```python 14 | import reflex_local_auth 15 | ``` 16 | 17 | ### Add the canned login and registration pages 18 | 19 | If you don't want to create your own login and registration forms, add the canned pages to your app: 20 | 21 | ```python 22 | app = rx.App() 23 | ... 24 | app.add_page( 25 | reflex_local_auth.pages.login_page, 26 | route=reflex_local_auth.routes.LOGIN_ROUTE, 27 | title="Login", 28 | ) 29 | app.add_page( 30 | reflex_local_auth.pages.register_page, 31 | route=reflex_local_auth.routes.REGISTER_ROUTE, 32 | title="Register", 33 | ) 34 | ``` 35 | 36 | ### Create Database Tables 37 | 38 | ```console 39 | reflex db init # if needed 40 | reflex db makemigrations 41 | reflex db migrate 42 | ``` 43 | 44 | ### Redirect Pages to Login 45 | 46 | Use the `@reflex_local_auth.require_login` decorator to redirect unauthenticated users to the LOGIN_ROUTE. 47 | 48 | ```python 49 | @rx.page() 50 | @reflex_local_auth.require_login 51 | def need2login(request): 52 | return rx.heading("Accessing this page will redirect to the login page if not authenticated.") 53 | ``` 54 | 55 | Although this _seems_ to protect the content, it is still publicly accessible 56 | when viewing the source code for the page! This should be considered a mechanism 57 | to redirect users to the login page, NOT a way to protect data. 58 | 59 | ### Protect State 60 | 61 | It is _extremely_ important to protect private data returned by State via Event 62 | Handlers! All static page data should be considered public, the only data that 63 | can truly be considered private at runtime must be fetched by an event handler 64 | that checks the authenticated user and assigns the data to a State Var. After 65 | the user logs out, the private data should be cleared and the user's tab should 66 | be closed to destroy the session identifier. 67 | 68 | ```python 69 | import reflex_local_auth 70 | 71 | 72 | class ProtectedState(reflex_local_auth.LocalAuthState): 73 | data: str 74 | 75 | def on_load(self): 76 | if not self.is_authenticated: 77 | return reflex_local_auth.LoginState.redir 78 | self.data = f"This is truly private data for {self.authenticated_user.username}" 79 | 80 | def do_logout(self): 81 | self.data = "" 82 | return reflex_local_auth.LocalAuthState.do_logout 83 | 84 | 85 | @rx.page(on_load=ProtectedState.on_load) 86 | @reflex_local_auth.require_login 87 | def protected_page(): 88 | return rx.heading(ProtectedState.data) 89 | ``` 90 | 91 | ## Customization 92 | 93 | The basic `reflex_local_auth.LocalUser` model provides password hashing and 94 | verification, and an enabled flag. Additional functionality can be added by 95 | creating a new `UserInfo` model and creating a foreign key relationship to the 96 | `user.id` field. 97 | 98 | ```python 99 | import sqlmodel 100 | import reflex as rx 101 | import reflex_local_auth 102 | 103 | 104 | class UserInfo(rx.Model, table=True): 105 | email: str 106 | is_admin: bool = False 107 | created_from_ip: str 108 | 109 | user_id: int = sqlmodel.Field(foreign_key="user.id") 110 | ``` 111 | 112 | To populate the extra fields, you can create a custom registration page and 113 | state that asks for the extra info, or it can be added via other event handlers. 114 | 115 | A custom registration state and form might look like: 116 | 117 | ```python 118 | import reflex as rx 119 | import reflex_local_auth 120 | from reflex_local_auth.pages.components import input_100w, MIN_WIDTH, PADDING_TOP 121 | 122 | 123 | class MyRegisterState(reflex_local_auth.RegistrationState): 124 | # This event handler must be named something besides `handle_registration`!!! 125 | def handle_registration_email(self, form_data): 126 | registration_result = self.handle_registration(form_data) 127 | if self.new_user_id >= 0: 128 | with rx.session() as session: 129 | session.add( 130 | UserInfo( 131 | email=form_data["email"], 132 | created_from_ip=self.router.headers.get( 133 | "x_forwarded_for", 134 | self.router.session.client_ip, 135 | ), 136 | user_id=self.new_user_id, 137 | ) 138 | ) 139 | session.commit() 140 | return registration_result 141 | 142 | 143 | def register_error() -> rx.Component: 144 | """Render the registration error message.""" 145 | return rx.cond( 146 | reflex_local_auth.RegistrationState.error_message != "", 147 | rx.callout( 148 | reflex_local_auth.RegistrationState.error_message, 149 | icon="triangle_alert", 150 | color_scheme="red", 151 | role="alert", 152 | width="100%", 153 | ), 154 | ) 155 | 156 | 157 | def register_form() -> rx.Component: 158 | """Render the registration form.""" 159 | return rx.form( 160 | rx.vstack( 161 | rx.heading("Create an account", size="7"), 162 | register_error(), 163 | rx.text("Username"), 164 | input_100w("username"), 165 | rx.text("Email"), 166 | input_100w("email"), 167 | rx.text("Password"), 168 | input_100w("password", type="password"), 169 | rx.text("Confirm Password"), 170 | input_100w("confirm_password", type="password"), 171 | rx.button("Sign up", width="100%"), 172 | rx.center( 173 | rx.link("Login", on_click=lambda: rx.redirect(reflex_local_auth.routes.LOGIN_ROUTE)), 174 | width="100%", 175 | ), 176 | min_width=MIN_WIDTH, 177 | ), 178 | on_submit=MyRegisterState.handle_registration_email, 179 | ) 180 | 181 | 182 | def register_page() -> rx.Component: 183 | """Render the registration page. 184 | 185 | Returns: 186 | A reflex component. 187 | """ 188 | 189 | return rx.center( 190 | rx.cond( 191 | reflex_local_auth.RegistrationState.success, 192 | rx.vstack( 193 | rx.text("Registration successful!"), 194 | ), 195 | rx.card(register_form()), 196 | ), 197 | padding_top=PADDING_TOP, 198 | ) 199 | ``` 200 | 201 | 202 | Finally you can create a substate of `reflex_local_auth.LocalAuthState` which fetches 203 | the associated `UserInfo` record and makes it available to your app. 204 | 205 | ```python 206 | from typing import Optional 207 | 208 | import sqlmodel 209 | import reflex as rx 210 | import reflex_local_auth 211 | 212 | 213 | class MyLocalAuthState(reflex_local_auth.LocalAuthState): 214 | @rx.var(cache=True) 215 | def authenticated_user_info(self) -> Optional[UserInfo]: 216 | if self.authenticated_user.id < 0: 217 | return 218 | with rx.session() as session: 219 | return session.exec( 220 | sqlmodel.select(UserInfo).where( 221 | UserInfo.user_id == self.authenticated_user.id 222 | ), 223 | ).one_or_none() 224 | 225 | 226 | @rx.page() 227 | @reflex_local_auth.require_login 228 | def user_info(): 229 | return rx.vstack( 230 | rx.text(f"Username: {MyLocalAuthState.authenticated_user.username}"), 231 | rx.cond( 232 | MyLocalAuthState.authenticated_user_info, 233 | rx.fragment( 234 | rx.text(f"Email: {MyLocalAuthState.authenticated_user_info.email}"), 235 | rx.text(f"Account Created From: {MyLocalAuthState.authenticated_user_info.created_from_ip}"), 236 | ), 237 | ), 238 | ) 239 | ``` 240 | 241 | ## Migrating from 0.0.x to 0.1.x 242 | 243 | The `User` model has been renamed to `LocalUser` and the `AuthSession` model has 244 | been renamed to `LocalAuthSession`. If your app was using reflex-local-auth 0.0.x, 245 | then you will need to make manual changes to migration script to copy existing user 246 | data into the new tables _after_ running `reflex db makemigrations`. 247 | 248 | See [`local_auth_demo/alembic/version/cb01e050df85_.py`](local_auth_demo/alembic/version/cb01e050df85_.py) for an example migration script. 249 | 250 | Importantly, your `upgrade` function should include the following lines, after creating 251 | the new tables and before dropping the old tables: 252 | 253 | ```python 254 | op.execute("INSERT INTO localuser SELECT * FROM user;") 255 | op.execute("INSERT INTO localauthsession SELECT * FROM authsession;") 256 | ``` -------------------------------------------------------------------------------- /custom_components/reflex_local_auth/__init__.py: -------------------------------------------------------------------------------- 1 | from . import pages, routes 2 | from .local_auth import LocalAuthState 3 | from .login import LoginState, require_login 4 | from .registration import RegistrationState 5 | from .routes import set_login_route, set_register_route 6 | from .user import LocalUser 7 | 8 | __all__ = [ 9 | "LocalAuthState", 10 | "LocalUser", 11 | "LoginState", 12 | "RegistrationState", 13 | "pages", 14 | "require_login", 15 | "routes", 16 | "set_login_route", 17 | "set_register_route", 18 | ] 19 | -------------------------------------------------------------------------------- /custom_components/reflex_local_auth/auth_session.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import reflex as rx 4 | from sqlmodel import Column, DateTime, Field, String, func 5 | 6 | 7 | class LocalAuthSession( 8 | rx.Model, 9 | table=True, # type: ignore 10 | ): 11 | """Correlate a session_id with an arbitrary user_id.""" 12 | 13 | user_id: int = Field(index=True, nullable=False) 14 | session_id: str = Field( 15 | unique=True, 16 | index=True, 17 | nullable=False, 18 | sa_type=String(255), # pyright: ignore[reportArgumentType] 19 | ) 20 | expiration: datetime.datetime = Field( 21 | sa_column=Column( 22 | DateTime(timezone=True), server_default=func.now(), nullable=False 23 | ), 24 | ) 25 | -------------------------------------------------------------------------------- /custom_components/reflex_local_auth/local_auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | Authentication data is stored in the LocalAuthState class so that all substates can 3 | access it for verifying access to event handlers and computed vars. 4 | 5 | Your app may inherit from LocalAuthState, or it may access it via the `get_state` API. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import datetime 11 | 12 | import reflex as rx 13 | from sqlmodel import select 14 | 15 | from .auth_session import LocalAuthSession 16 | from .user import LocalUser 17 | 18 | AUTH_TOKEN_LOCAL_STORAGE_KEY = "_auth_token" 19 | DEFAULT_AUTH_SESSION_EXPIRATION_DELTA = datetime.timedelta(days=7) 20 | DEFAULT_AUTH_REFRESH_DELTA = datetime.timedelta(minutes=10) 21 | 22 | 23 | class LocalAuthState(rx.State): 24 | # The auth_token is stored in local storage to persist across tab and browser sessions. 25 | auth_token: str = rx.LocalStorage(name=AUTH_TOKEN_LOCAL_STORAGE_KEY) 26 | 27 | @rx.var(cache=True, interval=DEFAULT_AUTH_REFRESH_DELTA) 28 | def authenticated_user(self) -> LocalUser: 29 | """The currently authenticated user, or a dummy user if not authenticated. 30 | 31 | Returns: 32 | A LocalUser instance with id=-1 if not authenticated, or the LocalUser instance 33 | corresponding to the currently authenticated user. 34 | """ 35 | with rx.session() as session: 36 | result = session.exec( 37 | select(LocalUser, LocalAuthSession).where( 38 | LocalAuthSession.session_id == self.auth_token, 39 | LocalAuthSession.expiration 40 | >= datetime.datetime.now(datetime.timezone.utc), 41 | LocalUser.id == LocalAuthSession.user_id, 42 | ), 43 | ).first() 44 | if result: 45 | user, session = result 46 | return user 47 | return LocalUser(id=-1) # type: ignore 48 | 49 | @rx.var(cache=True, interval=DEFAULT_AUTH_REFRESH_DELTA) 50 | def is_authenticated(self) -> bool: 51 | """Whether the current user is authenticated. 52 | 53 | Returns: 54 | True if the authenticated user has a positive user ID, False otherwise. 55 | """ 56 | return ( 57 | self.authenticated_user.id is not None and self.authenticated_user.id >= 0 58 | ) 59 | 60 | @rx.event 61 | def do_logout(self): 62 | """Destroy LocalAuthSessions associated with the auth_token.""" 63 | with rx.session() as session: 64 | for auth_session in session.exec( 65 | select(LocalAuthSession).where( 66 | LocalAuthSession.session_id == self.auth_token 67 | ) 68 | ).all(): 69 | session.delete(auth_session) 70 | session.commit() 71 | self.auth_token = self.auth_token 72 | 73 | def _login( 74 | self, 75 | user_id: int, 76 | expiration_delta: datetime.timedelta = DEFAULT_AUTH_SESSION_EXPIRATION_DELTA, 77 | ) -> None: 78 | """Create an LocalAuthSession for the given user_id. 79 | 80 | If the auth_token is already associated with an LocalAuthSession, it will be 81 | logged out first. 82 | 83 | Args: 84 | user_id: The user ID to associate with the LocalAuthSession. 85 | expiration_delta: The amount of time before the LocalAuthSession expires. 86 | """ 87 | self.do_logout() 88 | if user_id < 0: 89 | return 90 | self.auth_token = self.auth_token or self.router.session.client_token 91 | with rx.session() as session: 92 | session.add( 93 | LocalAuthSession( # type: ignore 94 | user_id=user_id, 95 | session_id=self.auth_token, 96 | expiration=datetime.datetime.now(datetime.timezone.utc) 97 | + expiration_delta, 98 | ) 99 | ) 100 | session.commit() 101 | -------------------------------------------------------------------------------- /custom_components/reflex_local_auth/login.py: -------------------------------------------------------------------------------- 1 | """Login state and authentication logic.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | import reflex as rx 8 | from sqlmodel import select 9 | 10 | from . import routes 11 | from .local_auth import LocalAuthState 12 | from .user import LocalUser 13 | 14 | 15 | class LoginState(LocalAuthState): 16 | """Handle login form submission and redirect to proper routes after authentication.""" 17 | 18 | error_message: str = "" 19 | redirect_to: str = "" 20 | 21 | @rx.event 22 | def on_submit(self, form_data: dict[str, Any]): 23 | """Handle login form on_submit. 24 | 25 | Args: 26 | form_data: A dict of form fields and values. 27 | """ 28 | self.error_message = "" 29 | username = form_data["username"] 30 | password = form_data["password"] 31 | with rx.session() as session: 32 | user = session.exec( 33 | select(LocalUser).where(LocalUser.username == username) 34 | ).one_or_none() 35 | if user is not None and not user.enabled: 36 | self.error_message = "This account is disabled." 37 | return rx.set_value("password", "") 38 | if ( 39 | user is not None 40 | and user.id is not None 41 | and user.enabled 42 | and password 43 | and user.verify(password) 44 | ): 45 | # mark the user as logged in 46 | self._login(user.id) 47 | else: 48 | self.error_message = "There was a problem logging in, please try again." 49 | return rx.set_value("password", "") 50 | self.error_message = "" 51 | return LoginState.redir() # type: ignore 52 | 53 | @rx.event 54 | def redir(self): 55 | """Redirect to the redirect_to route if logged in, or to the login page if not.""" 56 | if not self.is_hydrated: 57 | # wait until after hydration to ensure auth_token is known 58 | return LoginState.redir() # type: ignore 59 | page = self.router.page.path 60 | if not self.is_authenticated and page != routes.LOGIN_ROUTE: 61 | self.redirect_to = self.router.page.raw_path 62 | return rx.redirect(routes.LOGIN_ROUTE) 63 | elif self.is_authenticated and page == routes.LOGIN_ROUTE: 64 | return rx.redirect(self.redirect_to or "/") 65 | 66 | 67 | def require_login(page: rx.app.ComponentCallable) -> rx.app.ComponentCallable: 68 | """Decorator to require authentication before rendering a page. 69 | 70 | If the user is not authenticated, then redirect to the login page. 71 | 72 | Args: 73 | page: The page to wrap. 74 | 75 | Returns: 76 | The wrapped page component. 77 | """ 78 | 79 | def protected_page(): 80 | return rx.fragment( 81 | rx.cond( 82 | LoginState.is_hydrated & LoginState.is_authenticated, # type: ignore 83 | page(), 84 | rx.center( 85 | # When this text mounts, it will redirect to the login page 86 | rx.text("Loading...", on_mount=LoginState.redir), 87 | ), 88 | ) 89 | ) 90 | 91 | protected_page.__name__ = page.__name__ 92 | return protected_page 93 | -------------------------------------------------------------------------------- /custom_components/reflex_local_auth/pages/__init__.py: -------------------------------------------------------------------------------- 1 | from . import components 2 | from .login import login_page 3 | from .registration import register_page 4 | 5 | __all__ = ["components", "login_page", "register_page"] 6 | -------------------------------------------------------------------------------- /custom_components/reflex_local_auth/pages/components.py: -------------------------------------------------------------------------------- 1 | import reflex as rx 2 | 3 | PADDING_TOP = "10vh" 4 | MIN_WIDTH = "50vw" 5 | 6 | 7 | def input_100w(name, **props) -> rx.Component: 8 | """Render a 100% width input. 9 | 10 | Returns: 11 | A reflex component. 12 | """ 13 | return rx.input( 14 | placeholder=name.replace("_", " ").title(), 15 | id=name, 16 | name=name, 17 | width="100%", 18 | **props, 19 | ) 20 | -------------------------------------------------------------------------------- /custom_components/reflex_local_auth/pages/login.py: -------------------------------------------------------------------------------- 1 | """An example login page that can be used as-is. 2 | 3 | app.add_page( 4 | reflex_local_auth.pages.login_page, 5 | route=reflex_local_auth.routes.LOGIN_ROUTE, 6 | title="Login", 7 | ) 8 | """ 9 | 10 | import reflex as rx 11 | 12 | from ..login import LoginState 13 | from ..registration import RegistrationState 14 | from .components import MIN_WIDTH, PADDING_TOP, input_100w 15 | 16 | 17 | def login_error() -> rx.Component: 18 | """Render the login error message.""" 19 | return rx.cond( 20 | LoginState.error_message != "", 21 | rx.callout( 22 | LoginState.error_message, 23 | icon="triangle_alert", 24 | color_scheme="red", 25 | role="alert", 26 | width="100%", 27 | ), 28 | ) 29 | 30 | 31 | def login_form() -> rx.Component: 32 | """Render the login form.""" 33 | return rx.form( 34 | rx.vstack( 35 | rx.heading("Login into your Account", size="7"), 36 | login_error(), 37 | rx.text("Username"), 38 | input_100w("username"), 39 | rx.text("Password"), 40 | input_100w("password", type="password"), 41 | rx.button("Sign in", width="100%"), 42 | rx.center( 43 | rx.link("Register", on_click=RegistrationState.redir), 44 | width="100%", 45 | ), 46 | min_width=MIN_WIDTH, 47 | ), 48 | on_submit=LoginState.on_submit, 49 | ) 50 | 51 | 52 | def login_page() -> rx.Component: 53 | """Render the login page. 54 | 55 | Returns: 56 | A reflex component. 57 | """ 58 | 59 | return rx.center( 60 | rx.cond( 61 | LoginState.is_hydrated, # type: ignore 62 | rx.card(login_form()), 63 | ), 64 | padding_top=PADDING_TOP, 65 | ) 66 | -------------------------------------------------------------------------------- /custom_components/reflex_local_auth/pages/registration.py: -------------------------------------------------------------------------------- 1 | """An example registration page that can be used as-is. 2 | 3 | app.add_page( 4 | reflex_local_auth.pages.register_page, 5 | route=reflex_local_auth.routes.REGISTER_ROUTE, 6 | title="Register", 7 | ) 8 | """ 9 | 10 | import reflex as rx 11 | 12 | from .. import routes 13 | from ..registration import RegistrationState 14 | from .components import MIN_WIDTH, PADDING_TOP, input_100w 15 | 16 | 17 | def register_error() -> rx.Component: 18 | """Render the registration error message.""" 19 | return rx.cond( 20 | RegistrationState.error_message != "", 21 | rx.callout( 22 | RegistrationState.error_message, 23 | icon="triangle_alert", 24 | color_scheme="red", 25 | role="alert", 26 | width="100%", 27 | ), 28 | ) 29 | 30 | 31 | def register_form() -> rx.Component: 32 | """Render the registration form.""" 33 | return rx.form( 34 | rx.vstack( 35 | rx.heading("Create an account", size="7"), 36 | register_error(), 37 | rx.text("Username"), 38 | input_100w("username"), 39 | rx.text("Password"), 40 | input_100w("password", type="password"), 41 | rx.text("Confirm Password"), 42 | input_100w("confirm_password", type="password"), 43 | rx.button("Sign up", width="100%"), 44 | rx.center( 45 | rx.link("Login", on_click=lambda: rx.redirect(routes.LOGIN_ROUTE)), 46 | width="100%", 47 | ), 48 | min_width=MIN_WIDTH, 49 | ), 50 | on_submit=RegistrationState.handle_registration, 51 | ) 52 | 53 | 54 | def register_page() -> rx.Component: 55 | """Render the registration page. 56 | 57 | Returns: 58 | A reflex component. 59 | """ 60 | 61 | return rx.center( 62 | rx.cond( 63 | RegistrationState.success, 64 | rx.vstack( 65 | rx.text("Registration successful!"), 66 | ), 67 | rx.card(register_form()), 68 | ), 69 | padding_top=PADDING_TOP, 70 | ) 71 | -------------------------------------------------------------------------------- /custom_components/reflex_local_auth/registration.py: -------------------------------------------------------------------------------- 1 | """New user registration validation and database logic.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | from typing import Any 7 | 8 | import reflex as rx 9 | from reflex.event import EventSpec 10 | from sqlmodel import select 11 | 12 | from . import routes 13 | from .local_auth import LocalAuthState 14 | from .user import LocalUser 15 | 16 | POST_REGISTRATION_DELAY = 0.5 17 | 18 | 19 | class RegistrationState(LocalAuthState): 20 | """Handle registration form submission and redirect to login page after registration.""" 21 | 22 | success: bool = False 23 | error_message: str = "" 24 | new_user_id: int = -1 25 | 26 | def _validate_fields( 27 | self, username, password, confirm_password 28 | ) -> EventSpec | list[EventSpec] | None: 29 | if not username: 30 | self.error_message = "Username cannot be empty" 31 | return rx.set_focus("username") 32 | with rx.session() as session: 33 | existing_user = session.exec( 34 | select(LocalUser).where(LocalUser.username == username) 35 | ).one_or_none() 36 | if existing_user is not None: 37 | self.error_message = ( 38 | f"Username {username} is already registered. Try a different name" 39 | ) 40 | return [rx.set_value("username", ""), rx.set_focus("username")] 41 | if not password: 42 | self.error_message = "Password cannot be empty" 43 | return rx.set_focus("password") 44 | if password != confirm_password: 45 | self.error_message = "Passwords do not match" 46 | return [ 47 | rx.set_value("confirm_password", ""), 48 | rx.set_focus("confirm_password"), 49 | ] 50 | 51 | def _register_user(self, username, password) -> None: 52 | with rx.session() as session: 53 | # Create the new user and add it to the database. 54 | new_user = LocalUser() # type: ignore 55 | new_user.username = username 56 | new_user.password_hash = LocalUser.hash_password(password) 57 | new_user.enabled = True 58 | session.add(new_user) 59 | session.commit() 60 | session.refresh(new_user) 61 | if new_user.id is not None: 62 | self.new_user_id = new_user.id 63 | 64 | @rx.event 65 | def handle_registration( 66 | self, 67 | form_data: dict[str, Any], 68 | ): 69 | """Handle registration form on_submit. 70 | 71 | Set error_message appropriately based on validation results. 72 | 73 | Args: 74 | form_data: A dict of form fields and values. 75 | """ 76 | username = form_data["username"] 77 | password = form_data["password"] 78 | validation_errors = self._validate_fields( 79 | username, password, form_data["confirm_password"] 80 | ) 81 | if validation_errors: 82 | self.new_user_id = -1 83 | return validation_errors 84 | self._register_user(username, password) 85 | return type(self).successful_registration 86 | 87 | @rx.event 88 | def set_success(self, success: bool): 89 | """Set the success flag. 90 | 91 | Args: 92 | success: Whether the registration was successful. 93 | """ 94 | self.success = success 95 | 96 | @rx.event 97 | async def successful_registration( 98 | self, 99 | ): 100 | # Set success and redirect to login page after a brief delay. 101 | self.error_message = "" 102 | self.new_user_id = -1 103 | self.success = True 104 | yield 105 | await asyncio.sleep(POST_REGISTRATION_DELAY) 106 | yield [rx.redirect(routes.LOGIN_ROUTE), type(self).set_success(False)] 107 | 108 | @rx.event 109 | def redir(self): 110 | """Redirect to the registration form.""" 111 | return rx.redirect(routes.REGISTER_ROUTE) 112 | -------------------------------------------------------------------------------- /custom_components/reflex_local_auth/routes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | LOGIN_ROUTE = "/login" 4 | REGISTER_ROUTE = "/register" 5 | 6 | 7 | def set_login_route(route: str) -> None: 8 | """Set the login route. 9 | 10 | Args: 11 | route: The route to set as the login route. 12 | """ 13 | global LOGIN_ROUTE 14 | LOGIN_ROUTE = route 15 | 16 | 17 | def set_register_route(route: str) -> None: 18 | """Set the register route. 19 | 20 | Args: 21 | route: The route to set as the register route. 22 | """ 23 | global REGISTER_ROUTE 24 | REGISTER_ROUTE = route 25 | -------------------------------------------------------------------------------- /custom_components/reflex_local_auth/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import bcrypt 4 | import reflex as rx 5 | from sqlmodel import Field, String 6 | 7 | 8 | class LocalUser( 9 | rx.Model, 10 | table=True, # type: ignore 11 | ): 12 | """A local User model with bcrypt password hashing.""" 13 | 14 | username: str = Field( 15 | unique=True, 16 | nullable=False, 17 | index=True, 18 | sa_type=String(255), # pyright: ignore[reportArgumentType] 19 | ) 20 | password_hash: bytes = Field(nullable=False) 21 | enabled: bool = False 22 | 23 | @staticmethod 24 | def hash_password(secret: str) -> bytes: 25 | """Hash the secret using bcrypt. 26 | 27 | Args: 28 | secret: The password to hash. 29 | 30 | Returns: 31 | The hashed password. 32 | """ 33 | return bcrypt.hashpw( 34 | password=secret.encode("utf-8"), 35 | salt=bcrypt.gensalt(), 36 | ) 37 | 38 | def verify(self, secret: str) -> bool: 39 | """Validate the user's password. 40 | 41 | Args: 42 | secret: The password to check. 43 | 44 | Returns: 45 | True if the hashed secret matches this user's password_hash. 46 | """ 47 | return bcrypt.checkpw( 48 | password=secret.encode("utf-8"), 49 | hashed_password=self.password_hash, 50 | ) 51 | 52 | def dict(self, *args, **kwargs) -> dict: 53 | """Return a dictionary representation of the user.""" 54 | d = super().dict(*args, **kwargs) 55 | # Never return the hash when serializing to the frontend. 56 | d.pop("password_hash", None) 57 | return d 58 | -------------------------------------------------------------------------------- /local_auth_demo/.gitignore: -------------------------------------------------------------------------------- 1 | .states 2 | *.db 3 | *.py[cod] 4 | .web 5 | __pycache__/ 6 | assets/external/ 7 | -------------------------------------------------------------------------------- /local_auth_demo/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = . 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python>=3.9 or backports.zoneinfo library. 20 | # Any required deps can installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to ZoneInfo() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to alembic/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # set to 'true' to search source files recursively 55 | # in each "version_locations" directory 56 | # new in Alembic version 1.10 57 | # recursive_version_locations = false 58 | 59 | # the output encoding used when revision files 60 | # are written from script.py.mako 61 | # output_encoding = utf-8 62 | 63 | sqlalchemy.url = driver://user:pass@localhost/dbname 64 | 65 | 66 | [post_write_hooks] 67 | # post_write_hooks defines scripts or Python functions that are run 68 | # on newly generated revision scripts. See the documentation for further 69 | # detail and examples 70 | 71 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 72 | # hooks = black 73 | # black.type = console_scripts 74 | # black.entrypoint = black 75 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 76 | 77 | # lint with attempts to fix using "ruff" - use the exec runner, execute a binary 78 | # hooks = ruff 79 | # ruff.type = exec 80 | # ruff.executable = %(here)s/.venv/bin/ruff 81 | # ruff.options = --fix REVISION_SCRIPT_FILENAME 82 | 83 | # Logging configuration 84 | [loggers] 85 | keys = root,sqlalchemy,alembic 86 | 87 | [handlers] 88 | keys = console 89 | 90 | [formatters] 91 | keys = generic 92 | 93 | [logger_root] 94 | level = WARN 95 | handlers = console 96 | qualname = 97 | 98 | [logger_sqlalchemy] 99 | level = WARN 100 | handlers = 101 | qualname = sqlalchemy.engine 102 | 103 | [logger_alembic] 104 | level = INFO 105 | handlers = 106 | qualname = alembic 107 | 108 | [handler_console] 109 | class = StreamHandler 110 | args = (sys.stderr,) 111 | level = NOTSET 112 | formatter = generic 113 | 114 | [formatter_generic] 115 | format = %(levelname)-5.5s [%(name)s] %(message)s 116 | datefmt = %H:%M:%S 117 | -------------------------------------------------------------------------------- /local_auth_demo/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /local_auth_demo/alembic/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from sqlalchemy import engine_from_config 4 | from sqlalchemy import pool 5 | 6 | from alembic import context 7 | 8 | # this is the Alembic Config object, which provides 9 | # access to the values within the .ini file in use. 10 | config = context.config 11 | 12 | # Interpret the config file for Python logging. 13 | # This line sets up loggers basically. 14 | if config.config_file_name is not None: 15 | fileConfig(config.config_file_name) 16 | 17 | # add your model's MetaData object here 18 | # for 'autogenerate' support 19 | # from myapp import mymodel 20 | # target_metadata = mymodel.Base.metadata 21 | target_metadata = None 22 | 23 | # other values from the config, defined by the needs of env.py, 24 | # can be acquired: 25 | # my_important_option = config.get_main_option("my_important_option") 26 | # ... etc. 27 | 28 | 29 | def run_migrations_offline() -> None: 30 | """Run migrations in 'offline' mode. 31 | 32 | This configures the context with just a URL 33 | and not an Engine, though an Engine is acceptable 34 | here as well. By skipping the Engine creation 35 | we don't even need a DBAPI to be available. 36 | 37 | Calls to context.execute() here emit the given string to the 38 | script output. 39 | 40 | """ 41 | url = config.get_main_option("sqlalchemy.url") 42 | context.configure( 43 | url=url, 44 | target_metadata=target_metadata, 45 | literal_binds=True, 46 | dialect_opts={"paramstyle": "named"}, 47 | ) 48 | 49 | with context.begin_transaction(): 50 | context.run_migrations() 51 | 52 | 53 | def run_migrations_online() -> None: 54 | """Run migrations in 'online' mode. 55 | 56 | In this scenario we need to create an Engine 57 | and associate a connection with the context. 58 | 59 | """ 60 | connectable = engine_from_config( 61 | config.get_section(config.config_ini_section, {}), 62 | prefix="sqlalchemy.", 63 | poolclass=pool.NullPool, 64 | ) 65 | 66 | with connectable.connect() as connection: 67 | context.configure( 68 | connection=connection, target_metadata=target_metadata 69 | ) 70 | 71 | with context.begin_transaction(): 72 | context.run_migrations() 73 | 74 | 75 | if context.is_offline_mode(): 76 | run_migrations_offline() 77 | else: 78 | run_migrations_online() 79 | -------------------------------------------------------------------------------- /local_auth_demo/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /local_auth_demo/alembic/versions/05267fabef38_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 05267fabef38 4 | Revises: 5 | Create Date: 2024-03-07 02:24:05.272667 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | import sqlmodel 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = '05267fabef38' 16 | down_revision: Union[str, None] = None 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table('authsession', 24 | sa.Column('id', sa.Integer(), nullable=False), 25 | sa.Column('user_id', sa.Integer(), nullable=False), 26 | sa.Column('session_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), 27 | sa.Column('expiration', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), 28 | sa.PrimaryKeyConstraint('id') 29 | ) 30 | op.create_index(op.f('ix_authsession_session_id'), 'authsession', ['session_id'], unique=True) 31 | op.create_index(op.f('ix_authsession_user_id'), 'authsession', ['user_id'], unique=False) 32 | op.create_table('user', 33 | sa.Column('id', sa.Integer(), nullable=False), 34 | sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False), 35 | sa.Column('password_hash', sa.LargeBinary(), nullable=False), 36 | sa.Column('enabled', sa.Boolean(), nullable=False), 37 | sa.PrimaryKeyConstraint('id') 38 | ) 39 | op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True) 40 | op.create_table('userinfo', 41 | sa.Column('id', sa.Integer(), nullable=False), 42 | sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), 43 | sa.Column('created_from_ip', sqlmodel.sql.sqltypes.AutoString(), nullable=False), 44 | sa.Column('user_id', sa.Integer(), nullable=False), 45 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), 46 | sa.PrimaryKeyConstraint('id') 47 | ) 48 | # ### end Alembic commands ### 49 | 50 | 51 | def downgrade() -> None: 52 | # ### commands auto generated by Alembic - please adjust! ### 53 | op.drop_table('userinfo') 54 | op.drop_index(op.f('ix_user_username'), table_name='user') 55 | op.drop_table('user') 56 | op.drop_index(op.f('ix_authsession_user_id'), table_name='authsession') 57 | op.drop_index(op.f('ix_authsession_session_id'), table_name='authsession') 58 | op.drop_table('authsession') 59 | # ### end Alembic commands ### 60 | -------------------------------------------------------------------------------- /local_auth_demo/alembic/versions/cb01e050df85_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: cb01e050df85 4 | Revises: 05267fabef38 5 | Create Date: 2024-04-10 13:12:57.109831 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | import sqlmodel 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = 'cb01e050df85' 16 | down_revision: Union[str, None] = '05267fabef38' 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table('localauthsession', 24 | sa.Column('id', sa.Integer(), nullable=False), 25 | sa.Column('user_id', sa.Integer(), nullable=False), 26 | sa.Column('session_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), 27 | sa.Column('expiration', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), 28 | sa.PrimaryKeyConstraint('id') 29 | ) 30 | op.create_index(op.f('ix_localauthsession_session_id'), 'localauthsession', ['session_id'], unique=True) 31 | op.create_index(op.f('ix_localauthsession_user_id'), 'localauthsession', ['user_id'], unique=False) 32 | op.create_table('localuser', 33 | sa.Column('id', sa.Integer(), nullable=False), 34 | sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False), 35 | sa.Column('password_hash', sa.LargeBinary(), nullable=False), 36 | sa.Column('enabled', sa.Boolean(), nullable=False), 37 | sa.PrimaryKeyConstraint('id') 38 | ) 39 | op.create_index(op.f('ix_localuser_username'), 'localuser', ['username'], unique=True) 40 | op.execute("INSERT INTO localuser SELECT * FROM user;") 41 | op.execute("INSERT INTO localauthsession SELECT * FROM authsession;") 42 | op.drop_index('ix_authsession_session_id', table_name='authsession') 43 | op.drop_index('ix_authsession_user_id', table_name='authsession') 44 | op.drop_table('authsession') 45 | naming_convention = { 46 | "fk": 47 | "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 48 | } 49 | with op.batch_alter_table('userinfo', naming_convention=naming_convention) as batch_op: 50 | batch_op.drop_constraint('fk_userinfo_user_id_user', type_='foreignkey') 51 | batch_op.create_foreign_key('fk_userinfo_user_id_localuser', 'localuser', ['user_id'], ['id']) 52 | op.drop_index('ix_user_username', table_name='user') 53 | op.drop_table('user') 54 | # ### end Alembic commands ### 55 | 56 | 57 | def downgrade() -> None: 58 | # ### commands auto generated by Alembic - please adjust! ### 59 | op.create_table('user', 60 | sa.Column('id', sa.INTEGER(), nullable=False), 61 | sa.Column('username', sa.VARCHAR(), nullable=False), 62 | sa.Column('password_hash', sa.BLOB(), nullable=False), 63 | sa.Column('enabled', sa.BOOLEAN(), nullable=False), 64 | sa.PrimaryKeyConstraint('id') 65 | ) 66 | op.create_index('ix_user_username', 'user', ['username'], unique=1) 67 | op.create_table('authsession', 68 | sa.Column('id', sa.INTEGER(), nullable=False), 69 | sa.Column('user_id', sa.INTEGER(), nullable=False), 70 | sa.Column('session_id', sa.VARCHAR(), nullable=False), 71 | sa.Column('expiration', sa.DATETIME(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), 72 | sa.PrimaryKeyConstraint('id') 73 | ) 74 | op.create_index('ix_authsession_user_id', 'authsession', ['user_id'], unique=False) 75 | op.create_index('ix_authsession_session_id', 'authsession', ['session_id'], unique=1) 76 | op.execute("INSERT INTO user SELECT * FROM localuser;") 77 | op.execute("INSERT INTO authsession SELECT * FROM localauthsession;") 78 | naming_convention = { 79 | "fk": 80 | "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 81 | } 82 | with op.batch_alter_table('userinfo', naming_convention=naming_convention) as batch_op: 83 | batch_op.drop_constraint('fk_userinfo_user_id_localuser', type_='foreignkey') 84 | batch_op.create_foreign_key('fk_userinfo_user_id_user', 'localuser', ['user_id'], ['id']) 85 | op.drop_index(op.f('ix_localuser_username'), table_name='localuser') 86 | op.drop_table('localuser') 87 | op.drop_index(op.f('ix_localauthsession_user_id'), table_name='localauthsession') 88 | op.drop_index(op.f('ix_localauthsession_session_id'), table_name='localauthsession') 89 | op.drop_table('localauthsession') 90 | # ### end Alembic commands ### 91 | -------------------------------------------------------------------------------- /local_auth_demo/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masenf/reflex-local-auth/512908b2fe1f1a548435b369dcb730afeaaf63dd/local_auth_demo/assets/favicon.ico -------------------------------------------------------------------------------- /local_auth_demo/local_auth_demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masenf/reflex-local-auth/512908b2fe1f1a548435b369dcb730afeaaf63dd/local_auth_demo/local_auth_demo/__init__.py -------------------------------------------------------------------------------- /local_auth_demo/local_auth_demo/custom_user_info.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | import reflex as rx 4 | import reflex_local_auth 5 | import sqlmodel 6 | from reflex_local_auth.pages.components import MIN_WIDTH, PADDING_TOP, input_100w 7 | 8 | 9 | class UserInfo(rx.Model, table=True): 10 | email: str 11 | created_from_ip: str 12 | 13 | user_id: int = sqlmodel.Field(foreign_key="localuser.id") 14 | 15 | 16 | class MyLocalAuthState(reflex_local_auth.LocalAuthState): 17 | @rx.var(cache=True) 18 | def authenticated_user_info(self) -> Optional[UserInfo]: 19 | if self.authenticated_user.id is not None and self.authenticated_user.id < 0: 20 | return 21 | with rx.session() as session: 22 | return session.exec( 23 | sqlmodel.select(UserInfo).where( 24 | UserInfo.user_id == self.authenticated_user.id 25 | ), 26 | ).one_or_none() 27 | 28 | 29 | class MyRegisterState(reflex_local_auth.RegistrationState): 30 | @rx.event 31 | def handle_registration_email(self, form_data: dict[str, Any]): 32 | registration_result = self.handle_registration(form_data) 33 | if self.new_user_id >= 0: 34 | with rx.session() as session: 35 | session.add( 36 | UserInfo( 37 | email=form_data["email"], 38 | created_from_ip=getattr( 39 | self.router.headers, 40 | "x_forwarded_for", 41 | self.router.session.client_ip, 42 | ), 43 | user_id=self.new_user_id, 44 | ) 45 | ) 46 | session.commit() 47 | return registration_result 48 | 49 | 50 | def register_error() -> rx.Component: 51 | """Render the registration error message.""" 52 | return rx.cond( 53 | reflex_local_auth.RegistrationState.error_message != "", 54 | rx.callout( 55 | reflex_local_auth.RegistrationState.error_message, 56 | icon="triangle_alert", 57 | color_scheme="red", 58 | role="alert", 59 | width="100%", 60 | ), 61 | ) 62 | 63 | 64 | def register_form() -> rx.Component: 65 | """Render the registration form.""" 66 | return rx.form( 67 | rx.vstack( 68 | rx.heading("Create an account with Email and IP tracking", size="7"), 69 | register_error(), 70 | rx.text("Username"), 71 | input_100w("username"), 72 | rx.text("Email"), 73 | input_100w("email"), 74 | rx.text("Password"), 75 | input_100w("password", type="password"), 76 | rx.text("Confirm Password"), 77 | input_100w("confirm_password", type="password"), 78 | rx.button("Sign up", width="100%"), 79 | rx.center( 80 | rx.link( 81 | "Login", 82 | on_click=lambda: rx.redirect(reflex_local_auth.routes.LOGIN_ROUTE), 83 | ), 84 | width="100%", 85 | ), 86 | min_width=MIN_WIDTH, 87 | ), 88 | on_submit=MyRegisterState.handle_registration_email, 89 | ) 90 | 91 | 92 | @rx.page(route="/custom-register") 93 | def register_page() -> rx.Component: 94 | """Render the registration page. 95 | 96 | Returns: 97 | A reflex component. 98 | """ 99 | 100 | return rx.center( 101 | rx.cond( 102 | reflex_local_auth.RegistrationState.success, 103 | rx.vstack( 104 | rx.text("Registration successful!"), 105 | ), 106 | rx.card(register_form()), 107 | ), 108 | padding_top=PADDING_TOP, 109 | ) 110 | 111 | 112 | @rx.page() 113 | @reflex_local_auth.require_login 114 | def user_info(): 115 | return rx.vstack( 116 | rx.text(f"Username: {MyLocalAuthState.authenticated_user.username}"), 117 | rx.cond( 118 | MyLocalAuthState.authenticated_user_info, 119 | rx.fragment( 120 | rx.text(f"Email: {MyLocalAuthState.authenticated_user_info.email}"), 121 | rx.text( 122 | f"Account Created From: {MyLocalAuthState.authenticated_user_info.created_from_ip}" 123 | ), 124 | ), 125 | rx.text(f"No extra UserInfo for {MyLocalAuthState.authenticated_user.id}"), 126 | ), 127 | align="center", 128 | ) 129 | -------------------------------------------------------------------------------- /local_auth_demo/local_auth_demo/local_auth_demo.py: -------------------------------------------------------------------------------- 1 | """Main app module to demo local authentication.""" 2 | 3 | import reflex as rx 4 | import reflex_local_auth 5 | 6 | from . import custom_user_info as custom_user_info 7 | 8 | 9 | def links() -> rx.Component: 10 | """Render the links for the demo.""" 11 | return rx.fragment( 12 | rx.link("Home", href="/"), 13 | rx.link("Need 2 Login", href="/need2login"), 14 | rx.link("Protected Page", href="/protected"), 15 | rx.link("Custom Register", href="/custom-register"), 16 | rx.link("User Info", href="/user-info"), 17 | rx.cond( 18 | reflex_local_auth.LocalAuthState.is_authenticated, 19 | rx.link( 20 | "Logout", 21 | href="/", 22 | on_click=reflex_local_auth.LocalAuthState.do_logout, 23 | ), 24 | rx.link("Login", href=reflex_local_auth.routes.LOGIN_ROUTE), 25 | ), 26 | ) 27 | 28 | 29 | @rx.page() 30 | def index() -> rx.Component: 31 | """Render the index page. 32 | 33 | Returns: 34 | A reflex component. 35 | """ 36 | return rx.fragment( 37 | rx.color_mode.button(position="top-right"), 38 | rx.vstack( 39 | rx.heading("Welcome to my homepage!", font_size="2em"), 40 | links(), 41 | spacing="2", 42 | padding_top="10%", 43 | align="center", 44 | ), 45 | ) 46 | 47 | 48 | @rx.page() 49 | @reflex_local_auth.require_login 50 | def need2login(): 51 | return rx.vstack( 52 | rx.heading( 53 | "Accessing this page will redirect to the login page if not authenticated." 54 | ), 55 | links(), 56 | spacing="2", 57 | padding_top="10%", 58 | align="center", 59 | ) 60 | 61 | 62 | class ProtectedState(reflex_local_auth.LocalAuthState): 63 | data: str 64 | 65 | @rx.event 66 | def on_load(self): 67 | if not self.is_authenticated: 68 | return reflex_local_auth.LoginState.redir 69 | self.data = f"This is truly private data for {self.authenticated_user.username}" 70 | 71 | @rx.event 72 | def do_logout(self): 73 | self.data = "" 74 | return reflex_local_auth.LocalAuthState.do_logout 75 | 76 | 77 | @rx.page(on_load=ProtectedState.on_load) 78 | @reflex_local_auth.require_login 79 | def protected(): 80 | return rx.vstack( 81 | rx.heading(ProtectedState.data), 82 | links(), 83 | spacing="2", 84 | padding_top="10%", 85 | align="center", 86 | ) 87 | 88 | 89 | app = rx.App(theme=rx.theme(has_background=True, accent_color="orange")) 90 | app.add_page( 91 | reflex_local_auth.pages.login_page, 92 | route=reflex_local_auth.routes.LOGIN_ROUTE, 93 | title="Login", 94 | ) 95 | app.add_page( 96 | reflex_local_auth.pages.register_page, 97 | route=reflex_local_auth.routes.REGISTER_ROUTE, 98 | title="Register", 99 | ) 100 | 101 | # Create the database if it does not exist (hosting service does not migrate automatically) 102 | rx.Model.migrate() 103 | -------------------------------------------------------------------------------- /local_auth_demo/requirements.txt: -------------------------------------------------------------------------------- 1 | reflex>=0.6.0a3 2 | reflex-chakra>=0.6.0a 3 | reflex_local_auth>=0.2.0 4 | -------------------------------------------------------------------------------- /local_auth_demo/rxconfig.py: -------------------------------------------------------------------------------- 1 | import reflex as rx 2 | 3 | config = rx.Config( 4 | app_name="local_auth_demo", 5 | ) 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel", "setuptools_scm>=8"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "reflex-local-auth" 7 | description = "Local DB user authentication for Reflex apps" 8 | readme = "README.md" 9 | license = { text = "Apache-2.0" } 10 | requires-python = ">=3.10" 11 | authors = [{ name = "Masen Furer", email = "m_github@0x26.net" }] 12 | keywords = [ 13 | "reflex", 14 | "reflex-custom-components"] 15 | 16 | dependencies = [ 17 | "reflex>=0.7.0a3", 18 | "bcrypt", 19 | ] 20 | 21 | classifiers = [ 22 | "Development Status :: 4 - Beta", 23 | ] 24 | 25 | dynamic = ["version"] 26 | 27 | [project.urls] 28 | Homepage = "https://github.com/masenf/reflex-local-auth" 29 | 30 | [project.optional-dependencies] 31 | dev = ["build", "twine"] 32 | 33 | [tool.setuptools.packages.find] 34 | where = ["custom_components"] 35 | 36 | [tool.ruff] 37 | target-version = "py310" 38 | output-format = "concise" 39 | lint.isort.split-on-trailing-comma = false 40 | lint.select = ["B", "C4", "E", "ERA", "F", "FURB", "I", "N", "PERF", "PTH", "RUF", "SIM", "T", "TRY", "W"] 41 | lint.ignore = ["B008", "D205", "E501", "F403", "SIM115", "RUF006", "RUF008", "RUF012", "TRY0"] 42 | lint.pydocstyle.convention = "google" 43 | include = ["custom_components/**/*.py", "*_demo/**/*.py"] 44 | exclude = ["*/alembic/*"] 45 | 46 | [tool.ruff.lint.per-file-ignores] 47 | "__init__.py" = ["F401"] 48 | "env.py" = ["ALL"] 49 | "*/alembic/**/*.py" = ["ALL"] 50 | 51 | [tool.pyright] 52 | exclude = ["*/alembic/*"] 53 | 54 | [tool.setuptools_scm] 55 | --------------------------------------------------------------------------------