├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yaml │ ├── config.yml │ └── feature_request.yaml └── workflows │ └── test.yaml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── config.yaml ├── docs ├── Makefile ├── conf.py ├── index.rst ├── make.bat └── requirements.txt ├── graphics ├── banner.png ├── forgot_password.JPG ├── forgot_username.JPG ├── guest_login_buttons.JPG ├── guest_login_google.JPG ├── guest_login_microsoft.JPG ├── incorrect_login.JPG ├── logged_in.JPG ├── login_form.JPG ├── logo.png ├── register_user.JPG ├── reset_password.JPG ├── two_factor_authentication.JPG ├── two_factor_authentication_email.JPG ├── two_factor_authentication_email_password.JPG ├── two_factor_authentication_email_username.JPG └── update_user_details.JPG ├── readthedocs.yaml ├── requirements.txt ├── setup.py ├── streamlit_authenticator ├── __init__.py ├── controllers │ ├── __init__.py │ ├── authentication_controller.py │ └── cookie_controller.py ├── models │ ├── __init__.py │ ├── authentication_model.py │ ├── cloud │ │ ├── __init__.py │ │ └── cloud_model.py │ ├── cookie_model.py │ └── oauth2 │ │ ├── __init__.py │ │ ├── google_model.py │ │ └── microsoft_model.py ├── params.py ├── utilities │ ├── __init__.py │ ├── encryptor.py │ ├── exceptions.py │ ├── hasher.py │ ├── helpers.py │ └── validator.py └── views │ ├── __init__.py │ └── authentication_view.py └── tests ├── app.py └── tests.py /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug to help us improve 3 | title: "[Bug] " 4 | labels: [bug] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to report a bug! Please fill in the details below. 10 | 11 | - type: input 12 | id: streamlit_version 13 | attributes: 14 | label: Streamlit Version 15 | placeholder: "e.g. v1.2.3" 16 | validations: 17 | required: true 18 | 19 | - type: input 20 | id: streamlit_authenticator_version 21 | attributes: 22 | label: Streamlit Authenticator Version 23 | placeholder: "e.g. v1.2.3" 24 | validations: 25 | required: true 26 | 27 | - type: input 28 | id: environment 29 | attributes: 30 | label: Environment 31 | description: e.g. OS, Python version, browser, etc. 32 | placeholder: "macOS 14, Python 3.10" 33 | validations: 34 | required: true 35 | 36 | - type: textarea 37 | id: what-happened 38 | attributes: 39 | label: What happened? 40 | description: Describe the bug. 41 | placeholder: Describe the issue here... 42 | validations: 43 | required: true 44 | 45 | - type: textarea 46 | id: expected 47 | attributes: 48 | label: What did you expect to happen? 49 | validations: 50 | required: false 51 | 52 | - type: textarea 53 | id: steps 54 | attributes: 55 | label: Steps to reproduce 56 | description: How can we reproduce the issue? 57 | placeholder: | 58 | 1. Go to '...' 59 | 2. Click on '....' 60 | 3. Scroll down to '....' 61 | 4. See error 62 | validations: 63 | required: true 64 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | title: "[Feature] " 4 | labels: [enhancement] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for suggesting a feature! Please provide a clear and concise description. 10 | 11 | - type: textarea 12 | id: description 13 | attributes: 14 | label: Describe the feature 15 | description: What should the feature do? 16 | placeholder: Describe your idea here... 17 | validations: 18 | required: true 19 | 20 | - type: textarea 21 | id: use-case 22 | attributes: 23 | label: Use case 24 | description: Why is this feature needed? How would it help? 25 | validations: 26 | required: true 27 | 28 | - type: textarea 29 | id: alternatives 30 | attributes: 31 | label: Alternatives considered 32 | description: Have you considered any alternatives? 33 | validations: 34 | required: false 35 | 36 | - type: input 37 | id: related 38 | attributes: 39 | label: Related Issues 40 | description: Link any related issues or PRs. 41 | placeholder: "#123" 42 | validations: 43 | required: false 44 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Streamlit Authenticator Testing 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v2 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: '3.9' 23 | 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install -r requirements.txt 28 | pip install pytest 29 | 30 | - name: Run Streamlit app and tests 31 | run: | 32 | nohup streamlit run tests/app.py & 33 | pytest tests/tests.py --maxfail=1 --disable-warnings -q 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .vscode/ 3 | graphics/logo.pptx 4 | /misc/ 5 | /docs/_build/ -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | khorasani.mohammad@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # Proprietary License 2 | 3 | ## License 4 | 5 | Copyright (C) [2025] [Mohammad Khorasani] 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to use the Software for any purpose, including non-personal or commercial use, subject to the following conditions: 8 | 9 | 1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | 2. You may not redistribute, sublicense, and/or sell copies of the Software, in whole or in part, as your own work without explicit written permission from the copyright holder. 12 | 13 | 3. Any modification, derivative works, or merged works that are distributed must not be presented as your own original work and must include the original copyright notice. 14 | 15 | 4. You must clearly attribute the original author of the Software in any derivative work. 16 | 17 | ## Disclaimer 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | 21 | ## Contact 22 | 23 | For permission to use the software or any inquiries, please contact [khorasani.mohammad@gmail.com]. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Streamlit Authenticator logo 2 | 3 | 4 | 5 | **A secure authentication module to manage user access in a Streamlit application** 6 | 7 | [![Downloads](https://static.pepy.tech/badge/streamlit-authenticator)](https://pepy.tech/project/streamlit-authenticator) 8 | [![Downloads](https://static.pepy.tech/badge/streamlit-authenticator/month)](https://pepy.tech/project/streamlit-authenticator) 9 | [![Downloads](https://static.pepy.tech/badge/streamlit-authenticator/week)](https://pepy.tech/project/streamlit-authenticator) 10 | 12 | 13 | 14 | 15 | ## Table of Contents 16 | - [Quickstart](#1-quickstart) 17 | - [Installation](#2-installation) 18 | - [Creating a config file](#3-creating-a-config-file) 19 | - [Setup](#4-setup) 20 | - [Creating a login widget](#5-creating-a-login-widget) 21 | - [Creating a guest login widget](#6-creating-a-guest-login-widget) 🚀 **NEW** 22 | - [Authenticating users](#7-authenticating-users) 23 | - [Enabling two factor authentication](#8-enabling-two-factor-authentication) 🚀 **NEW** 24 | - [Creating a reset password widget](#9-creating-a-reset-password-widget) 25 | - [Creating a new user registration widget](#10-creating-a-new-user-registration-widget) 26 | - [Creating a forgot password widget](#11-creating-a-forgot-password-widget) 27 | - [Creating a forgot username widget](#12-creating-a-forgot-username-widget) 28 | - [Creating an update user details widget](#13-creating-an-update-user-details-widget) 29 | - [Updating the config file](#14-updating-the-config-file) 30 | - [License](#license) 31 | 32 | ### 1. Quickstart 33 | 34 | * Subscribe to receive a free [API key](https://stauthenticator.com/) 35 | * Check out the [demo app](https://st-demo-application.streamlit.app/). 36 | * Feel free to visit the [API reference](https://streamlit-authenticator.readthedocs.io/en/stable/). 37 | * And finally follow the tutorial below. 38 | 39 | ### 2. Installation 40 | 41 | Streamlit-Authenticator is distributed via [PyPI](https://pypi.org/project/streamlit-authenticator/): 42 | 43 | ```python 44 | pip install streamlit-authenticator 45 | ``` 46 | 47 | Using Streamlit-Authenticator is as simple as importing the module and calling it to verify your user's credentials. 48 | 49 | ```python 50 | import streamlit as st 51 | import streamlit_authenticator as stauth 52 | ``` 53 | 54 | ### 3. Creating a config file 55 | 56 | * Create a YAML config file and add to it your user's credentials: including username, email, first name, last name, and password (plain text passwords will be hashed automatically). 57 | * Enter a name, random key, and number of days to expiry, for a re-authentication cookie that will be stored on the client's browser to enable password-less re-authentication. If you do not require re-authentication, you may set the number of days to expiry to 0. 58 | * Define an optional list of pre-authorized emails of users who are allowed to register and add their credentials to the config file using the **register_user** widget. 59 | * Add the optional configuration parameters for OAuth2 if you wish to use the **experimental_guest_login** button. 60 | * **_Please remember to update the config file (as shown in step 14) whenever the contents are modified or after using any of the widgets or buttons._** 61 | 62 | ```python 63 | cookie: 64 | expiry_days: 30 65 | key: # To be filled with any string 66 | name: # To be filled with any string 67 | credentials: 68 | usernames: 69 | jsmith: 70 | email: jsmith@gmail.com 71 | failed_login_attempts: 0 # Will be managed automatically 72 | first_name: John 73 | last_name: Smith 74 | logged_in: False # Will be managed automatically 75 | password: abc # Will be hashed automatically 76 | roles: # Optional 77 | - admin 78 | - editor 79 | - viewer 80 | rbriggs: 81 | email: rbriggs@gmail.com 82 | failed_login_attempts: 0 # Will be managed automatically 83 | first_name: Rebecca 84 | last_name: Briggs 85 | logged_in: False # Will be managed automatically 86 | password: def # Will be hashed automatically 87 | roles: # Optional 88 | - viewer 89 | oauth2: # Optional 90 | google: # Follow instructions: https://developers.google.com/identity/protocols/oauth2 91 | client_id: # To be filled 92 | client_secret: # To be filled 93 | redirect_uri: # URL to redirect to after OAuth2 authentication 94 | microsoft: # Follow instructions: https://learn.microsoft.com/en-us/graph/auth-register-app-v2 95 | client_id: # To be filled 96 | client_secret: # To be filled 97 | redirect_uri: # URL to redirect to after OAuth2 authentication 98 | tenant_id: # To be filled 99 | pre-authorized: # Optional 100 | emails: 101 | - melsby@gmail.com 102 | api_key: # Optional - register to receive a free API key: https://stauthenticator.com 103 | ``` 104 | 105 | * _Please note that the 'failed_login_attempts' and 'logged_in' fields corresponding to each user's number of failed login attempts and log-in status in the credentials will be added and managed automatically._ 106 | 107 | ### 4. Setup 108 | 109 | * Subsequently import the config file into your script and create an authentication object. 110 | 111 | ```python 112 | import yaml 113 | from yaml.loader import SafeLoader 114 | 115 | with open('../config.yaml') as file: 116 | config = yaml.load(file, Loader=SafeLoader) 117 | 118 | # Pre-hashing all plain text passwords once 119 | # stauth.Hasher.hash_passwords(config['credentials']) 120 | 121 | authenticator = stauth.Authenticate( 122 | config['credentials'], 123 | config['cookie']['name'], 124 | config['cookie']['key'], 125 | config['cookie']['expiry_days'] 126 | ) 127 | ``` 128 | 129 | * Plain text passwords will be hashed automatically by default, however, for a large number of users it is recommended to pre-hash the passwords in the credentials using the **Hasher.hash_passwords** function. 130 | * If you choose to pre-hash the passwords, please set the **auto_hash** parameter in the **Authenticate** class to False. 131 | 132 | > ### Hasher.hash_passwords 133 | > #### Parameters: 134 | > - **credentials:** _dict_ 135 | > - The credentials dict with plain text passwords. 136 | > #### Returns: 137 | > - _dict_ 138 | > - The credentials dict with hashed passwords. 139 | 140 | > ### Authenticate 141 | > #### Parameters: 142 | > - **credentials:** _dict, str_ 143 | > - Dictionary with the usernames, names, passwords, and emails, and other user data, or path pointing to the location of the config file. 144 | > - **cookie_name:** _str_ 145 | > - Specifies the name of the re-authentication cookie stored on the client's browser for password-less re-authentication. 146 | > - **cookie_key:** _str_ 147 | > - Specifies the key that will be used to hash the signature of the re-authentication cookie. 148 | > - **cookie_expiry_days:** _float, default 30.0_ 149 | > - Specifies the number of days before the re-authentication cookie automatically expires on the client's browser. 150 | > - **validator:** _Validator, optional, default None_ 151 | > - Provides a validator object that will check the validity of the username, name, and email fields. 152 | > - **auto_hash:** _bool, default True_ 153 | > - Automatic hashing requirement for passwords, True: plain text passwords will be hashed automatically, False: plain text passwords will not be hashed automatically. 154 | > - **api_key:** _str, optional, default None_ 155 | > - API key used to connect to the cloud server to send two factor authorization codes, reset passwords, and forgotten usernames to the user by email. 156 | > - ****kwargs:** _dict, optional_ 157 | > - Arguments to pass to the Authenticate class. 158 | 159 | * **_Please remember to pass the authenticator object to each and every page in a multi-page application as a session state variable._** 160 | 161 | ### 5. Creating a login widget 162 | 163 | * You can render the **login** widget as follows. 164 | 165 | ```python 166 | try: 167 | authenticator.login() 168 | except Exception as e: 169 | st.error(e) 170 | ``` 171 | 172 | > ### Authenticate.login 173 | > #### Parameters: 174 | > - **location:** _str, {'main', 'sidebar', 'unrendered'}, default 'main'_ 175 | > - Specifies the location of the login widget. 176 | > - **max_concurrent_users:** _int, optional, default None_ 177 | > - Limits the number of concurrent users. If not specified there will be no limit to the number of concurrently logged in users. 178 | > - **max_login_attempts:** _int, optional, default None_ 179 | > - Limits the number of failed login attempts. If not specified there will be no limit to the number of failed login attempts. 180 | > - **fields:** _dict, optional, default {'Form name':'Login', 'Username':'Username', 'Password':'Password', 'Login':'Login', 'Captcha':'Captcha'}_ 181 | > - Customizes the text of headers, buttons and other fields. 182 | > - **captcha:** _bool, default False_ 183 | > - Specifies the captcha requirement for the login widget, True: captcha required, False: captcha removed. 184 | > - **single_session:** _bool, default False_ 185 | > - Disables the ability for the same user to log in multiple sessions, True: single session allowed, False: multiple sessions allowed. 186 | > - **clear_on_submit:** _bool, default False_ 187 | > - Specifies the clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. 188 | > - **key:** _str, default 'Login'_ 189 | > - Unique key provided to widget to avoid duplicate WidgetID errors. 190 | > - **callback:** _callable, optional, default None_ 191 | > - Callback function that will be invoked on form submission with a dict as a parameter. 192 | 193 | ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/login_form.JPG) 194 | 195 | * **_Please remember to re-invoke an 'unrendered' login widget on each and every page in a multi-page application._** 196 | * **_Please remember to update the config file (as shown in step 14) after you use this widget._** 197 | 198 | ### 6. Creating a guest login widget 199 | 200 | * You may use the **experimental_guest_login** button to log in non-registered users with their Google or Microsoft accounts using OAuth2. 201 | * To create the client ID and client secret parameters for Google OAuth2 please refer to [Google's documentation](https://developers.google.com/identity/protocols/oauth2). 202 | * To create the client ID, client secret, and tenant ID parameters for Microsoft OAuth2 please refer to [Microsoft's documentation](https://learn.microsoft.com/en-us/graph/auth-register-app-v2). 203 | * Once you have created the OAuth2 configuration parameters, add them to the config file as shown in step 3. 204 | 205 | ```python 206 | try: 207 | authenticator.experimental_guest_login('Login with Google', 208 | provider='google', 209 | oauth2=config['oauth2']) 210 | authenticator.experimental_guest_login('Login with Microsoft', 211 | provider='microsoft', 212 | oauth2=config['oauth2']) 213 | except Exception as e: 214 | st.error(e) 215 | ``` 216 | 217 | > ### Authenticate.experimental_guest_login 218 | > #### Parameters: 219 | > - **button_name:** _str, default 'Guest login'_ 220 | > - Rendered name of the guest login button. 221 | > - **location:** _str, {'main', 'sidebar'}, default 'main'_ 222 | > - Specifies the location of the guest login button. 223 | > - **provider:** _str, {'google', 'microsoft'}, default 'google'_ 224 | > - Selection for OAuth2 provider, Google or Microsoft. 225 | > - **oauth2:** _dict, optional, default None_ 226 | > - Configuration parameters to implement an OAuth2 authentication. 227 | > - **max_concurrent_users:** _int, optional, default None_ 228 | > - Limits the number of concurrent users. If not specified there will be no limit to the number of concurrently logged in users. 229 | > - **single_session:** _bool, default False_ 230 | > - Disables the ability for the same user to log in multiple sessions, True: single session allowed, False: multiple sessions allowed. 231 | > - **roles:** _list, optional, default None_ 232 | > - User roles for guest users. 233 | > - **use_container_width:** _bool, default False_ 234 | > - Button width setting, True: width will match container, False: width will fit to button contents. 235 | > - **callback:** _callable, optional, default None_ 236 | > - Callback function that will be invoked on button press with a dict as a parameter. 237 | 238 | ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/guest_login_buttons.JPG) 239 | 240 | ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/guest_login_google.JPG) 241 | 242 | ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/guest_login_microsoft.JPG) 243 | 244 | * Please note that upon successful login, the guest user's name, email, and other information will be registered in the credentials dictionary and their re-authentication cookie will be saved automatically. 245 | * **_Please remember to update the config file (as shown in step 14) after you use this widget._** 246 | 247 | ### 7. Authenticating users 248 | 249 | * You can then retrieve the name, authentication status, username, and roles from Streamlit's session state using the keys **'name'**, **'authentication_status'**, **'username'**, and **'roles'** to allow a verified user to access restricted content. 250 | * You may also render a logout button, or may choose not to render the button if you only need to implement the logout logic programmatically. 251 | * The optional **key** parameter for the logout button should be used with multi-page applications to prevent Streamlit from throwing duplicate key errors. 252 | 253 | ```python 254 | if st.session_state.get('authentication_status'): 255 | authenticator.logout() 256 | st.write(f'Welcome *{st.session_state.get("name")}*') 257 | st.title('Some content') 258 | elif st.session_state.get('authentication_status') is False: 259 | st.error('Username/password is incorrect') 260 | elif st.session_state.get('authentication_status') is None: 261 | st.warning('Please enter your username and password') 262 | ``` 263 | 264 | > ### Authenticate.logout 265 | > #### Parameters: 266 | > - **button_name:** _str, default 'Logout'_ 267 | > - Customizes the button name. 268 | > - **location:** _str, {'main', 'sidebar', 'unrendered'}, default 'main'_ 269 | > - Specifies the location of the logout button. If 'unrendered' is passed, the logout logic will be executed without rendering the button. 270 | > - **key:** _str, default None_ 271 | > - Unique key that should be used in multi-page applications. 272 | > - **use_container_width:** _bool, default False_ 273 | > - Button width setting, True: width will match container, False: width will fit to button contents. 274 | > - **callback:** _callable, optional, default None_ 275 | > - Callback function that will be invoked on form submission with a dict as a parameter. 276 | 277 | ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/logged_in.JPG) 278 | 279 | * Or prompt an unverified user to enter a correct username and password. 280 | 281 | ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/incorrect_login.JPG) 282 | 283 | * You may also retrieve the number of failed login attempts a user has made by accessing **st.session_state.get('failed_login_attempts')** which returns a dictionary with the username as key and the number of failed attempts as the value. 284 | 285 | ### 8. Enabling two factor authentication 286 | 287 | * You may enable two factor authentication for the **register_user**, **forgot_password**, and **forgot_username** widgets for enhanced security. 288 | * First register to receive a free API key [here](https://stauthenticator.com/). 289 | * Then add your API key to the the authenticator object as **api_key** or alternatively add it to the config file as shown in step 3. 290 | * Finally set the **two_factor_auth** parameter for the widget to True, this will prompt the user to enter a four digit code sent to their email. 291 | 292 | ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/two_factor_authentication.JPG) 293 | 294 | ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/two_factor_authentication_email.JPG) 295 | 296 | * For the **forgot_password** and **forgot_username** widgets if you require the returned password and username to be sent to the user's email then you may set the **send_email** parameter to True. 297 | 298 | ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/two_factor_authentication_email_password.JPG) 299 | 300 | ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/two_factor_authentication_email_username.JPG) 301 | 302 | * **_Please check your spam folder to ensure emails are not being filtered._** 303 | 304 | ### 9. Creating a reset password widget 305 | 306 | * You may use the **reset_password** widget to allow a logged in user to modify their password as shown below. 307 | 308 | ```python 309 | if st.session_state.get('authentication_status'): 310 | try: 311 | if authenticator.reset_password(st.session_state.get('username')): 312 | st.success('Password modified successfully') 313 | except Exception as e: 314 | st.error(e) 315 | ``` 316 | 317 | > ### Authenticate.reset_password 318 | > #### Parameters: 319 | > - **username:** _str_ 320 | > - Specifies the username of the user to reset the password for. 321 | > - **location:** _str, {'main', 'sidebar'}, default 'main'_ 322 | > - Specifies the location of the reset password widget. 323 | > - **fields:** _dict, optional, default {'Form name':'Reset password', 'Current password':'Current password', 'New password':'New password', 'Repeat password': 'Repeat password', 'Reset':'Reset'}_ 324 | > - Customizes the text of headers, buttons and other fields. 325 | > - **clear_on_submit:** _bool, default False_ 326 | > - Specifies the clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. 327 | > - **key:** _str, default 'Reset password'_ 328 | > - Unique key provided to widget to avoid duplicate WidgetID errors. 329 | > - **callback:** _callable, optional, default None_ 330 | > - Callback function that will be invoked on form submission with a dict as a parameter. 331 | > #### Returns:: 332 | > - _bool_ 333 | > - Status of resetting the password. 334 | 335 | ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/reset_password.JPG) 336 | 337 | * **_Please remember to update the config file (as shown in step 14) after you use this widget._** 338 | 339 | ### 10. Creating a new user registration widget 340 | 341 | * You may use the **register_user** widget to allow a user to sign up to your application as shown below. 342 | * If you require the user to be pre-authorized, define a **pre_authorized** list of emails that are allowed to register, and add it to the config file or provide it as a parameter to the **register_user** widget. 343 | * Once they have registered, their email will be automatically removed from the **pre_authorized** list. 344 | * Alternatively, to allow anyone to sign up, do not provide a **pre_authorized** list. 345 | 346 | ```python 347 | try: 348 | email_of_registered_user, \ 349 | username_of_registered_user, \ 350 | name_of_registered_user = authenticator.register_user(pre_authorized=config['pre-authorized']['emails']) 351 | if email_of_registered_user: 352 | st.success('User registered successfully') 353 | except Exception as e: 354 | st.error(e) 355 | ``` 356 | 357 | > ### Authenticate.register_user 358 | > #### Parameters: 359 | > - **location:** _str, {'main', 'sidebar'}, default 'main'_ 360 | > - Specifies the location of the register user widget. 361 | > - **pre_authorized:** _list, optional, default None_ 362 | > - List of emails of unregistered users who are authorized to register. If no list is provided, all users will be allowed to register. 363 | > - **domains:** _list, optional, default None_ 364 | > - Specifies the required list of domains a new email must belong to i.e. ['gmail.com', 'yahoo.com'], list: the required list of domains, None: any domain is allowed. 365 | > - **fields:** _dict, optional, default {'Form name':'Register user', 'Email':'Email', 'Username':'Username', 'Password':'Password', 'Repeat password':'Repeat password', 'Password hint':'Password hint', 'Captcha':'Captcha', 'Register':'Register'}_ 366 | > - Customizes the text of headers, buttons and other fields. 367 | > - **captcha:** _bool, default True_ 368 | > - Specifies the captcha requirement for the register user widget, True: captcha required, False: captcha removed. 369 | > - **roles:** _list, optional, default None_ 370 | > - User roles for registered users. 371 | > - **merge_username_email:** _bool, default False_ 372 | > - Merges username into email field, True: username will be the same as the email, False: username and email will be independent. 373 | > - **password_hint:** _bool, default True_ 374 | > - Requirement for entering a password hint, True: password hint field added, False: password hint field removed. 375 | > - **two_factor_auth:** _bool, default False_ 376 | > - Specifies whether to enable two factor authentication for the forgot password widget, True: two factor authentication enabled, False: two factor authentication disabled. 377 | > - **clear_on_submit:** _bool, default False_ 378 | > - Specifies the clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. 379 | > - **key:** _str, default 'Register user'_ 380 | > - Unique key provided to widget to avoid duplicate WidgetID errors. 381 | > - **callback:** _callable, optional, default None_ 382 | > - Callback function that will be invoked on form submission with a dict as a parameter. 383 | > #### Returns: 384 | > - _str_ 385 | > - Email associated with the new user. 386 | > - _str_ 387 | > - Username associated with the new user. 388 | > - _str_ 389 | > - Name associated with the new user. 390 | 391 | ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/register_user.JPG) 392 | 393 | * **_Please remember to update the config file (as shown in step 14) after you use this widget._** 394 | 395 | ### 11. Creating a forgot password widget 396 | 397 | * You may use the **forgot_password** widget to allow a user to generate a new random password. 398 | * The new password will be automatically hashed and saved in the credentials dictionary. 399 | * The widget will return the username, email, and new random password which the developer can then transfer to the user securely using the send email feature shown in step 8. 400 | 401 | ```python 402 | try: 403 | username_of_forgotten_password, \ 404 | email_of_forgotten_password, \ 405 | new_random_password = authenticator.forgot_password() 406 | if username_of_forgotten_password: 407 | st.success('New password to be sent securely') 408 | # To securely transfer the new password to the user please see step 8. 409 | elif username_of_forgotten_password == False: 410 | st.error('Username not found') 411 | except Exception as e: 412 | st.error(e) 413 | ``` 414 | 415 | > ### Authenticate.forgot_password 416 | > #### Parameters 417 | > - **location:** _str, {'main', 'sidebar'}, default 'main'_ 418 | > - Specifies the location of the forgot password widget. 419 | > - **fields:** _dict, optional, default {'Form name':'Forgot password', 'Username':'Username', 'Captcha':'Captcha', 'Submit':'Submit'}_ 420 | > - Customizes the text of headers, buttons and other fields. 421 | > - **captcha:** _bool, default False_ 422 | > - Specifies the captcha requirement for the forgot password widget, True: captcha required, False: captcha removed. 423 | > - **send_email:** _bool, default False_ 424 | > - Specifies whether to send the generated password to the user's email, True: password will be sent to user's email, False: password will not be sent to user's email. 425 | > - **two_factor_auth:** _bool, default False_ 426 | > - Specifies whether to enable two factor authentication for the forgot password widget, True: two factor authentication enabled, False: two factor authentication disabled. 427 | > - **clear_on_submit:** _bool, default False_ 428 | > - Specifies the clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. 429 | > - **key:** _str, default 'Forgot password'_ 430 | > - Unique key provided to widget to avoid duplicate WidgetID errors. 431 | > - **callback:** _callable, optional, default None_ 432 | > - Callback function that will be invoked on form submission with a dict as a parameter. 433 | > #### Returns: 434 | > - _str_ 435 | > - Username associated with the forgotten password. 436 | > - _str_ 437 | > - Email associated with the forgotten password. 438 | > - _str_ 439 | > - New plain text password that should be transferred to the user securely. 440 | 441 | ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/forgot_password.JPG) 442 | 443 | * **_Please remember to update the config file (as shown in step 14) after you use this widget._** 444 | 445 | ### 12. Creating a forgot username widget 446 | 447 | * You may use the **forgot_username** widget to allow a user to retrieve their forgotten username. 448 | * The widget will return the username and email which the developer can then transfer to the user securely using the send email feature shown in step 8. 449 | 450 | ```python 451 | try: 452 | username_of_forgotten_username, \ 453 | email_of_forgotten_username = authenticator.forgot_username() 454 | if username_of_forgotten_username: 455 | st.success('Username to be sent securely') 456 | # To securely transfer the username to the user please see step 8. 457 | elif username_of_forgotten_username == False: 458 | st.error('Email not found') 459 | except Exception as e: 460 | st.error(e) 461 | ``` 462 | 463 | > ### Authenticate.forgot_username 464 | > #### Parameters 465 | > - **location:** _str, {'main', 'sidebar'}, default 'main'_ 466 | > - Specifies the location of the forgot username widget. 467 | > - **fields:** _dict, optional, default {'Form name':'Forgot username', 'Email':'Email', 'Captcha':'Captcha', 'Submit':'Submit'}_ 468 | > - Customizes the text of headers, buttons and other fields. 469 | > - **captcha:** _bool, default False_ 470 | > - Specifies the captcha requirement for the forgot username widget, True: captcha required, False: captcha removed. 471 | > - **send_email:** _bool, default False_ 472 | > - Specifies whether to send the retrieved username to the user's email, True: username will be sent to user's email, False: username will not be sent to user's email. 473 | > - **two_factor_auth:** _bool, default False_ 474 | > - Specifies whether to enable two factor authentication for the forgot username widget, True: two factor authentication enabled, False: two factor authentication disabled. 475 | > - **clear_on_submit:** _bool, default False_ 476 | > - Specifies the clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. 477 | > - **key:** _str, default 'Forgot username'_ 478 | > - Unique key provided to widget to avoid duplicate WidgetID errors. 479 | > - **callback:** _callable, optional, default None_ 480 | > - Callback function that will be invoked on form submission with a dict as a parameter. 481 | > #### Returns: 482 | > - _str_ 483 | > - Forgotten username that should be transferred to the user securely. 484 | > - _str_ 485 | > - Email associated with the forgotten username. 486 | 487 | ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/forgot_username.JPG) 488 | 489 | ### 13. Creating an update user details widget 490 | 491 | * You may use the **update_user_details** widget to allow a logged in user to update their name and/or email. 492 | * The widget will automatically save the updated details in both the credentials dictionary and re-authentication cookie. 493 | 494 | ```python 495 | if st.session_state.get('authentication_status'): 496 | try: 497 | if authenticator.update_user_details(st.session_state.get('username')): 498 | st.success('Entries updated successfully') 499 | except Exception as e: 500 | st.error(e) 501 | ``` 502 | 503 | > ### Authenticate.update_user_details 504 | > #### Parameters 505 | > - **username:** _str_ 506 | > - Specifies the username of the user to update user details for. 507 | > - **location:** _str, {'main', 'sidebar'}, default 'main'_ 508 | > - Specifies the location of the update user details widget. 509 | > - **fields:** _dict, optional, default {'Form name':'Update user details', 'Field':'Field', 'First name':'First name', 'Last name':'Last name', 'Email':'Email', 'New value':'New value', 'Update':'Update'}_ 510 | > - Customizes the text of headers, buttons and other fields. 511 | > - **clear_on_submit:** _bool, default False_ 512 | > - Specifies the clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. 513 | > - **key:** _str, default 'Update user details'_ 514 | > - Unique key provided to widget to avoid duplicate WidgetID errors. 515 | > - **callback:** _callable, optional, default None_ 516 | > - Callback function that will be invoked on form submission with a dict as a parameter. 517 | > #### Returns: 518 | > - _bool_ 519 | > - Status of updating the user details. 520 | 521 | ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/update_user_details.JPG) 522 | 523 | * **_Please remember to update the config file (as shown in step 14) after you use this widget._** 524 | 525 | ### 14. Updating the config file 526 | 527 | * Please ensure that the config file is re-saved whenever the contents are modified or after using any of the widgets or buttons. 528 | 529 | ```python 530 | with open('../config.yaml', 'w') as file: 531 | yaml.dump(config, file, default_flow_style=False, allow_unicode=True) 532 | ``` 533 | * Please note that this step is not required if you are providing the config file as a path to the **Authenticate** class. 534 | 535 | ## License 536 | 537 | This project is proprietary software. The use of this software is governed by the terms specified in the [LICENSE](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/LICENSE) file. Unauthorized copying, modification, or distribution of this software is prohibited. 538 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | api_key: null 2 | cookie: 3 | expiry_days: 30 4 | key: some_key 5 | name: some_cookie_name 6 | credentials: 7 | usernames: 8 | jsmith: 9 | email: jsmith@gmail.com 10 | failed_login_attempts: 0 11 | first_name: John 12 | last_name: Smith 13 | logged_in: false 14 | password: $2b$12$lhOfbg3eMo9/TIcgFOvlbuYTGJQBdTLU/ek3SiDOooJNrIktqfKr6 15 | roles: 16 | - admin 17 | - editor 18 | - viewer 19 | rbriggs: 20 | email: rbriggs@gmail.com 21 | failed_login_attempts: 0 22 | first_name: Rebecca 23 | last_name: Briggs 24 | logged_in: false 25 | password: $2b$12$SxD/z1xJN6C7G7ygeiCuDOR88ZFAMoky9nCm9JgACEuJiVbVV6/4a 26 | roles: 27 | - viewer 28 | oauth2: 29 | google: 30 | client_id: null 31 | client_secret: null 32 | redirect_uri: null 33 | microsoft: 34 | client_id: null 35 | client_secret: null 36 | redirect_uri: null 37 | tenant_id: null 38 | pre-authorized: 39 | emails: 40 | - melsby@gmail.com 41 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'Streamlit Authenticator' 21 | copyright = '2024, Mohammad Khorasani' 22 | author = 'Mohammad Khorasani' 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = 'v0.3.3' 26 | 27 | import os 28 | import sys 29 | sys.path.insert(0, os.path.abspath('../streamlit_authenticator/views')) 30 | 31 | # -- General configuration --------------------------------------------------- 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = [ 37 | 'sphinx.ext.autodoc', 38 | 'sphinx.ext.napoleon', 39 | 'sphinx.ext.viewcode', 40 | 'sphinx.ext.intersphinx', 41 | 'sphinx.ext.mathjax' 42 | ] 43 | 44 | # Napoleon settings 45 | napoleon_google_docstring = False 46 | napoleon_numpy_docstring = True 47 | 48 | # Add any paths that contain templates here, relative to this directory. 49 | templates_path = ['_templates'] 50 | 51 | # List of patterns, relative to source directory, that match files and 52 | # directories to ignore when looking for source files. 53 | # This pattern also affects html_static_path and html_extra_path. 54 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 55 | 56 | 57 | # -- Options for HTML output ------------------------------------------------- 58 | 59 | # The theme to use for HTML and HTML Help pages. See the documentation for 60 | # a list of builtin themes. 61 | # 62 | html_theme = 'sphinx_rtd_theme' 63 | 64 | # Add any paths that contain custom static files (such as style sheets) here, 65 | # relative to this directory. They are copied after the builtin static files, 66 | # so a file named "default.css" will overwrite the builtin "default.css". 67 | html_static_path = ['_static'] 68 | 69 | add_module_names = False # This will remove the module path from class names 70 | 71 | html_theme_options = { 72 | 'collapse_navigation': False, 73 | 'navigation_depth': -1, 74 | } 75 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Streamlit Authenticator documentation master file, created by 2 | sphinx-quickstart on Thu Aug 22 10:45:34 2024. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Streamlit Authenticator's documentation! 7 | =================================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | .. automodule:: streamlit_authenticator.views.authentication_view 14 | :members: 15 | 16 | .. automodule:: streamlit_authenticator.utilities.hasher 17 | :members: 18 | 19 | .. automodule:: streamlit_authenticator.utilities.validator 20 | :members: 21 | 22 | Indices and tables 23 | ================== 24 | 25 | * :ref:`genindex` 26 | * :ref:`modindex` 27 | * :ref:`search` 28 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx_rtd_theme 2 | streamlit_authenticator 3 | -------------------------------------------------------------------------------- /graphics/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkhorasani/Streamlit-Authenticator/de75639568e3ba12575197ef7a77eb8d5c412397/graphics/banner.png -------------------------------------------------------------------------------- /graphics/forgot_password.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkhorasani/Streamlit-Authenticator/de75639568e3ba12575197ef7a77eb8d5c412397/graphics/forgot_password.JPG -------------------------------------------------------------------------------- /graphics/forgot_username.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkhorasani/Streamlit-Authenticator/de75639568e3ba12575197ef7a77eb8d5c412397/graphics/forgot_username.JPG -------------------------------------------------------------------------------- /graphics/guest_login_buttons.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkhorasani/Streamlit-Authenticator/de75639568e3ba12575197ef7a77eb8d5c412397/graphics/guest_login_buttons.JPG -------------------------------------------------------------------------------- /graphics/guest_login_google.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkhorasani/Streamlit-Authenticator/de75639568e3ba12575197ef7a77eb8d5c412397/graphics/guest_login_google.JPG -------------------------------------------------------------------------------- /graphics/guest_login_microsoft.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkhorasani/Streamlit-Authenticator/de75639568e3ba12575197ef7a77eb8d5c412397/graphics/guest_login_microsoft.JPG -------------------------------------------------------------------------------- /graphics/incorrect_login.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkhorasani/Streamlit-Authenticator/de75639568e3ba12575197ef7a77eb8d5c412397/graphics/incorrect_login.JPG -------------------------------------------------------------------------------- /graphics/logged_in.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkhorasani/Streamlit-Authenticator/de75639568e3ba12575197ef7a77eb8d5c412397/graphics/logged_in.JPG -------------------------------------------------------------------------------- /graphics/login_form.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkhorasani/Streamlit-Authenticator/de75639568e3ba12575197ef7a77eb8d5c412397/graphics/login_form.JPG -------------------------------------------------------------------------------- /graphics/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkhorasani/Streamlit-Authenticator/de75639568e3ba12575197ef7a77eb8d5c412397/graphics/logo.png -------------------------------------------------------------------------------- /graphics/register_user.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkhorasani/Streamlit-Authenticator/de75639568e3ba12575197ef7a77eb8d5c412397/graphics/register_user.JPG -------------------------------------------------------------------------------- /graphics/reset_password.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkhorasani/Streamlit-Authenticator/de75639568e3ba12575197ef7a77eb8d5c412397/graphics/reset_password.JPG -------------------------------------------------------------------------------- /graphics/two_factor_authentication.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkhorasani/Streamlit-Authenticator/de75639568e3ba12575197ef7a77eb8d5c412397/graphics/two_factor_authentication.JPG -------------------------------------------------------------------------------- /graphics/two_factor_authentication_email.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkhorasani/Streamlit-Authenticator/de75639568e3ba12575197ef7a77eb8d5c412397/graphics/two_factor_authentication_email.JPG -------------------------------------------------------------------------------- /graphics/two_factor_authentication_email_password.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkhorasani/Streamlit-Authenticator/de75639568e3ba12575197ef7a77eb8d5c412397/graphics/two_factor_authentication_email_password.JPG -------------------------------------------------------------------------------- /graphics/two_factor_authentication_email_username.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkhorasani/Streamlit-Authenticator/de75639568e3ba12575197ef7a77eb8d5c412397/graphics/two_factor_authentication_email_username.JPG -------------------------------------------------------------------------------- /graphics/update_user_details.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkhorasani/Streamlit-Authenticator/de75639568e3ba12575197ef7a77eb8d5c412397/graphics/update_user_details.JPG -------------------------------------------------------------------------------- /readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Required 2 | version: 2 3 | 4 | # Set the OS, Python version and other tools you might need 5 | build: 6 | os: ubuntu-22.04 7 | tools: 8 | python: "3.12" 9 | 10 | # Build documentation in the "docs/" directory with Sphinx 11 | sphinx: 12 | configuration: docs/conf.py 13 | 14 | # Declare the Python requirements required to build your documentation 15 | python: 16 | install: 17 | - requirements: docs/requirements.txt 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bcrypt==3.1.7 2 | captcha==0.5.0 3 | cryptography==42.0.5 4 | extra-streamlit-components==0.1.70 5 | PyJWT==2.3.0 6 | pytest==7.0.0 7 | PyYAML==5.3.1 8 | streamlit==1.37.0 9 | streamlit-authenticator==0.4.2 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="streamlit-authenticator", 8 | version="0.4.2", 9 | author="Mohammad Khorasani", 10 | author_email="khorasani.mohammad@gmail.com", 11 | description="A secure authentication module to manage user access in a Streamlit application.", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/mkhorasani/Streamlit-Authenticator", 15 | packages=setuptools.find_packages(), 16 | include_package_data=True, 17 | classifiers=[ 18 | "Programming Language :: Python :: 3", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | ], 22 | keywords=['Python', 'Streamlit', 'Authentication', 'Components'], 23 | python_requires=">=3.6", 24 | install_requires=[ 25 | "bcrypt >= 3.1.7", 26 | "captcha >= 0.5.0", 27 | "cryptography >= 42.0.5", 28 | "extra-streamlit-components >= 0.1.70", 29 | "PyJWT >=2.3.0", 30 | "PyYAML >= 5.3.1", 31 | "streamlit >= 1.37.0" 32 | ], 33 | ) 34 | -------------------------------------------------------------------------------- /streamlit_authenticator/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script description: This script imports the main module of this library 3 | and also provides unit testing commands for development. 4 | 5 | Libraries imported: 6 | ------------------- 7 | - yaml: Module implementing the data serialization used for human readable documents. 8 | - streamlit: Framework used to build pure Python web applications. 9 | """ 10 | 11 | import yaml 12 | import streamlit as st 13 | import streamlit.components.v1 as components 14 | from yaml.loader import SafeLoader 15 | 16 | from .views import Authenticate 17 | from .utilities import * 18 | 19 | _RELEASE = True 20 | 21 | if not _RELEASE: 22 | # Loading config file 23 | with open('../config.yaml', 'r', encoding='utf-8') as file: 24 | config = yaml.load(file, Loader=SafeLoader) 25 | 26 | # Pre-hashing all plain text passwords once 27 | # Hasher.hash_passwords(config['credentials']) 28 | 29 | # Creating the authenticator object 30 | authenticator = Authenticate( 31 | config['credentials'], 32 | config['cookie']['name'], 33 | config['cookie']['key'], 34 | config['cookie']['expiry_days'] 35 | ) 36 | 37 | # authenticator = Authenticate( 38 | # '../config.yaml' 39 | # ) 40 | 41 | # Creating a login widget 42 | try: 43 | authenticator.login() 44 | except LoginError as e: 45 | st.error(e) 46 | 47 | # Creating a guest login button 48 | try: 49 | authenticator.experimental_guest_login('Login with Google', provider='google', 50 | oauth2=config['oauth2']) 51 | authenticator.experimental_guest_login('Login with Microsoft', provider='microsoft', 52 | oauth2=config['oauth2']) 53 | except LoginError as e: 54 | st.error(e) 55 | 56 | # Authenticating user 57 | if st.session_state.get('authentication_status'): 58 | authenticator.logout() 59 | st.write(f'Welcome *{st.session_state["name"]}*') 60 | st.title('Some content') 61 | elif st.session_state.get('authentication_status') is False: 62 | st.error('Username/password is incorrect') 63 | elif st.session_state.get('authentication_status') is None: 64 | st.warning('Please enter your username and password') 65 | 66 | # Creating a password reset widget 67 | if st.session_state.get('authentication_status'): 68 | try: 69 | if authenticator.reset_password(st.session_state['username']): 70 | st.success('Password modified successfully') 71 | except (CredentialsError, ResetError) as e: 72 | st.error(e) 73 | 74 | # Creating a new user registration widget 75 | try: 76 | (email_of_registered_user, 77 | username_of_registered_user, 78 | name_of_registered_user) = authenticator.register_user() 79 | if email_of_registered_user: 80 | st.success('User registered successfully') 81 | except (CloudError, RegisterError) as e: 82 | st.error(e) 83 | 84 | # Creating a forgot password widget 85 | try: 86 | (username_of_forgotten_password, 87 | email_of_forgotten_password, 88 | new_random_password) = authenticator.forgot_password(two_factor_auth=True, send_email=True) 89 | if username_of_forgotten_password: 90 | st.success('New password sent securely') 91 | # Random password to be transferred to the user securely 92 | elif not username_of_forgotten_password: 93 | st.error('Username not found') 94 | except (CloudError, ForgotError) as e: 95 | st.error(e) 96 | 97 | # Creating a forgot username widget 98 | try: 99 | (username_of_forgotten_username, 100 | email_of_forgotten_username) = authenticator.forgot_username(two_factor_auth=True, send_email=True) 101 | if username_of_forgotten_username: 102 | st.success('Username sent securely') 103 | # Username to be transferred to the user securely 104 | elif not username_of_forgotten_username: 105 | st.error('Email not found') 106 | except (CloudError, ForgotError) as e: 107 | st.error(e) 108 | 109 | # Creating an update user details widget 110 | if st.session_state.get('authentication_status'): 111 | try: 112 | if authenticator.update_user_details(st.session_state['username']): 113 | st.success('Entry updated successfully') 114 | except UpdateError as e: 115 | st.error(e) 116 | 117 | # Saving config file 118 | with open('../config.yaml', 'w', encoding='utf-8') as file: 119 | yaml.dump(config, file, default_flow_style=False, allow_unicode=True) 120 | -------------------------------------------------------------------------------- /streamlit_authenticator/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | from .authentication_controller import AuthenticationController 2 | from .cookie_controller import CookieController 3 | -------------------------------------------------------------------------------- /streamlit_authenticator/controllers/authentication_controller.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script description: This module controls authentication-related requests, including login, 3 | logout, user registration, password reset, and user modifications. 4 | 5 | Libraries imported: 6 | ------------------- 7 | - json: Handles JSON documents. 8 | - typing: Provides standard type hints for Python functions. 9 | - streamlit: Framework for building web applications. 10 | """ 11 | 12 | import json 13 | from typing import Any, Callable, Dict, List, Optional, Tuple, Type 14 | import streamlit as st 15 | 16 | from ..models import AuthenticationModel 17 | from ..utilities import (Encryptor, 18 | ForgotError, 19 | Helpers, 20 | LoginError, 21 | RegisterError, 22 | ResetError, 23 | UpdateError, 24 | Validator) 25 | 26 | 27 | class AuthenticationController: 28 | """ 29 | Controls authentication-related requests, including login, logout, user registration, 30 | password reset, and user modifications. 31 | """ 32 | def __init__( 33 | self, 34 | credentials: Optional[Dict[str, Any]] = None, 35 | validator: Optional[Validator] = None, 36 | auto_hash: bool = True, 37 | path: Optional[str] = None, 38 | api_key: Optional[str] = None, 39 | secret_key: str = 'some_key', 40 | server_url: Optional[str] = None) -> None: 41 | """ 42 | Initializes the AuthenticationController instance. 43 | 44 | Parameters 45 | ---------- 46 | credentials : dict, optional 47 | Dictionary containing usernames, names, passwords, emails, and other user data. 48 | validator : Validator, optional 49 | Validator object for checking the validity of usernames, names, and email fields. 50 | auto_hash : bool, default=True 51 | If True, plain-text passwords will be automatically hashed. 52 | path : str, optional 53 | File path of the configuration file. 54 | api_key : str, optional 55 | API key for connecting to the cloud server for password resets and two-factor 56 | authentication. 57 | secret_key : str, default='some_key' 58 | Secret key used for encryption and decryption. 59 | server_url : str, optional 60 | Cloud server URL used for cloud-related transactions. 61 | """ 62 | self.secret_key = secret_key 63 | self.validator = validator if validator is not None else Validator() 64 | self.authentication_model = AuthenticationModel(credentials, auto_hash, path, api_key, 65 | self.secret_key, server_url, self.validator) 66 | self.encryptor = Encryptor(self.secret_key) 67 | def _check_captcha(self, captcha_name: str, exception: Type[Exception], entered_captcha: str 68 | ) -> None: 69 | """ 70 | Validates the entered captcha against the generated captcha stored in session state. 71 | 72 | Parameters 73 | ---------- 74 | captcha_name : str 75 | The session state key where the generated captcha is stored. 76 | exception : Exception 77 | The exception to raise if captcha validation fails. 78 | entered_captcha : str 79 | The captcha value entered by the user. 80 | """ 81 | if Helpers.check_captcha(captcha_name, entered_captcha, self.secret_key): 82 | del st.session_state[captcha_name] 83 | else: 84 | raise exception('Captcha entered incorrectly') 85 | def check_two_factor_auth_code(self, code: str, content: Optional[Dict[str, Any]] = None, 86 | widget: Optional[str]=None) -> bool: 87 | """ 88 | Verifies the two-factor authentication code. 89 | 90 | Parameters 91 | ---------- 92 | code : str 93 | Entered two-factor authentication code. 94 | content : dict, optional 95 | Content to save in session state upon successful verification. 96 | widget : str, optional 97 | Widget name used in session state. 98 | 99 | Returns 100 | ------- 101 | bool 102 | True if the authentication code is correct, False otherwise. 103 | """ 104 | if code == self.encryptor.decrypt(st.session_state[f'2FA_code_{widget}']): 105 | st.session_state[f'2FA_check_{widget}'] = True 106 | st.session_state[f'2FA_content_{widget}'] = \ 107 | self.encryptor.encrypt(json.dumps(content)) if content else None 108 | del st.session_state[f'2FA_code_{widget}'] 109 | return True 110 | st.session_state[f'2FA_check_{widget}'] = False 111 | return False 112 | def forgot_password(self, username: str, callback: Optional[Callable] = None, 113 | captcha: bool = False, entered_captcha: Optional[str] = None 114 | ) -> Tuple[Optional[str], Optional[str], Optional[str]]: 115 | """ 116 | Handles user password reset requests. 117 | 118 | Parameters 119 | ---------- 120 | username : str 121 | Username associated with the forgotten password. 122 | callback : Callable, optional 123 | Function to be executed upon successful password reset. 124 | captcha : bool, default=False 125 | If True, a captcha check is required. 126 | entered_captcha : str, optional 127 | User-entered captcha value for validation. 128 | 129 | Returns 130 | ------- 131 | Tuple[Optional[str], Optional[str], Optional[str]] 132 | Tuple containing (username, email, new password), or (None, None, None) if unsuccessful. 133 | """ 134 | username = username.lower().strip() 135 | if captcha: 136 | if not entered_captcha: 137 | raise ForgotError('Captcha not entered') 138 | entered_captcha = entered_captcha.strip() 139 | self._check_captcha('forgot_password_captcha', ForgotError, entered_captcha) 140 | if not self.validator.validate_length(username, 1): 141 | raise ForgotError('Username not provided') 142 | return self.authentication_model.forgot_password(username, callback) 143 | def forgot_username(self, email: str, callback: Optional[Callable] = None, 144 | captcha: bool = False, entered_captcha: Optional[str] = None 145 | ) -> Tuple[Optional[str], Optional[str]]: 146 | """ 147 | Handles forgotten username requests. 148 | 149 | Parameters 150 | ---------- 151 | email: str 152 | Email associated with the forgotten username. 153 | callback : Callable, optional 154 | Function to be executed upon successful password reset. 155 | captcha : bool, default=False 156 | If True, a captcha check is required. 157 | entered_captcha : str, optional 158 | User-entered captcha value for validation. 159 | 160 | Returns 161 | ------- 162 | Tuple[Optional[str], Optional[str]] 163 | Tuple containing (username, email), or (None, None) if unsuccessful. 164 | """ 165 | email = email.strip() 166 | if captcha: 167 | if not entered_captcha: 168 | raise ForgotError('Captcha not entered') 169 | entered_captcha = entered_captcha.strip() 170 | self._check_captcha('forgot_username_captcha', ForgotError, entered_captcha) 171 | if not self.validator.validate_length(email, 1): 172 | raise ForgotError('Email not provided') 173 | return self.authentication_model.forgot_username(email, callback) 174 | def generate_two_factor_auth_code(self, email: str, widget: Optional[str] = None) -> str: 175 | """ 176 | Handles requests to generate a two factor authentication code. 177 | 178 | Parameters 179 | ---------- 180 | email : str 181 | Email to send two factor authentication code to. 182 | widget : str, optional 183 | Widget name to append to session state variable name. 184 | """ 185 | self.authentication_model.generate_two_factor_auth_code(email, widget) 186 | def guest_login(self, cookie_controller: Any, provider: str = 'google', 187 | oauth2: Optional[Dict[str, Any]] = None, 188 | max_concurrent_users: Optional[int] = None, 189 | single_session: bool = False, roles: Optional[List[str]] = None, 190 | callback: Optional[Callable] = None) -> Optional[str]: 191 | """ 192 | Handles guest login via OAuth2 providers. 193 | 194 | Parameters 195 | ---------- 196 | cookie_controller : CookieController 197 | Cookie controller object used to set the re-authentication cookie. 198 | provider : str 199 | OAuth2 provider selection i.e. google or microsoft. 200 | oauth2 : dict, optional 201 | Configuration parameters to implement an OAuth2 authentication. 202 | max_concurrent_users : int, optional 203 | Maximum number of users allowed to login concurrently. 204 | single_session : bool, default=False 205 | If True, prevents multiple logins from the same user. 206 | roles : list, optional 207 | User roles for guest users. 208 | callback : callable, optional 209 | Callback function that will be invoked on button press. 210 | 211 | Returns 212 | ------- 213 | Optional[str] 214 | Redirect URL if authentication requires further steps, otherwise None. 215 | """ 216 | if roles and not isinstance(roles, list): 217 | raise LoginError('Roles must be provided as a list') 218 | return self.authentication_model.guest_login(cookie_controller=cookie_controller, 219 | provider=provider, oauth2=oauth2, roles=roles, 220 | max_concurrent_users=max_concurrent_users, 221 | single_session=single_session, 222 | callback=callback) 223 | def login(self, username: Optional[str] = None, password: Optional[str] = None, 224 | max_concurrent_users: Optional[int] = None, max_login_attempts: Optional[int] = None, 225 | token: Optional[Dict[str, str]] = None, single_session: bool = False, 226 | callback: Optional[Callable] = None, 227 | captcha: bool = False, entered_captcha: Optional[str] = None) -> Optional[bool]: 228 | """ 229 | Handles user login requests. 230 | 231 | Parameters 232 | ---------- 233 | username : str, optional 234 | The username of the user being logged in. 235 | password : str, optional 236 | The entered password. 237 | max_concurrent_users : int, optional 238 | Maximum number of users allowed to login concurrently. 239 | max_login_attempts : int, optional 240 | Maximum number of failed login attempts a user can make. 241 | token : dict, optional 242 | Re-authentication token for retrieving the username. 243 | single_session : bool, default=False 244 | If True, prevents multiple logins from the same user. 245 | callback : Callable, optional 246 | Function to be executed upon successful login. 247 | captcha : bool, default=False 248 | If True, a captcha check is required. 249 | entered_captcha : str, optional 250 | User-entered captcha value for validation. 251 | 252 | Returns 253 | ------- 254 | bool or None 255 | True if login is successful, False if it fails, or None if no credentials are provided. 256 | """ 257 | if username and password: 258 | username = username.lower().strip() 259 | password = password.strip() 260 | if captcha: 261 | if not entered_captcha: 262 | raise LoginError('Captcha not entered') 263 | entered_captcha = entered_captcha.strip() 264 | self._check_captcha('login_captcha', LoginError, entered_captcha) 265 | return self.authentication_model.login(username, password, max_concurrent_users, 266 | max_login_attempts, token, single_session, 267 | callback) 268 | def logout(self, callback: Optional[Callable]=None) -> None: 269 | """ 270 | Logs out the user by clearing session state variables. 271 | 272 | Parameters 273 | ---------- 274 | callback: Callable, optional 275 | Function to be executed upon logout. 276 | """ 277 | self.authentication_model.logout(callback) 278 | def register_user(self, new_first_name: str, new_last_name: str, new_email: str, 279 | new_username: str, new_password: str, new_password_repeat: str, 280 | password_hint: str, pre_authorized: Optional[List[str]] = None, 281 | domains: Optional[List[str]] = None, roles: Optional[List[str]] = None, 282 | callback: Optional[Callable] = None, captcha: bool = False, 283 | entered_captcha: Optional[str] = None) -> Tuple[str, str, str]: 284 | """ 285 | Handles user registration requests. 286 | 287 | Parameters 288 | ---------- 289 | new_first_name: str 290 | First name of the new user. 291 | new_last_name: str 292 | Last name of the new user. 293 | new_email: str 294 | Email of the new user. 295 | new_username: str 296 | Username of the new user. 297 | new_password: str 298 | Password of the new user. 299 | new_password_repeat: str 300 | Repeated password of the new user. 301 | password_hint: str 302 | A hint for remembering the password. 303 | pre-authorized: list, optional 304 | List of emails of unregistered users who are authorized to register. 305 | domains: list, optional 306 | Required list of domains a new email must belong to i.e. ['gmail.com', 'yahoo.com'], 307 | list: the required list of domains, 308 | None: any domain is allowed. 309 | roles: list, optional 310 | User roles for registered users. 311 | callback : Callable, optional 312 | Function to be executed upon successful login. 313 | captcha : bool, default=False 314 | If True, a captcha check is required. 315 | entered_captcha : str, optional 316 | User-entered captcha value for validation. 317 | 318 | Returns 319 | ------- 320 | Tuple[str, str, str] 321 | Tuple containing (email, username, full name). 322 | """ 323 | new_first_name = new_first_name.strip() 324 | new_last_name = new_last_name.strip() 325 | new_email = new_email.strip() 326 | new_username = new_username.lower().strip() 327 | new_password = new_password.strip() 328 | new_password_repeat = new_password_repeat.strip() 329 | password_hint = password_hint.strip() if password_hint else None 330 | if not self.validator.validate_name(new_first_name): 331 | raise RegisterError('First name is not valid') 332 | if not self.validator.validate_name(new_last_name): 333 | raise RegisterError('Last name is not valid') 334 | if not self.validator.validate_email(new_email): 335 | raise RegisterError('Email is not valid') 336 | if domains and new_email.split('@')[-1] not in domains: 337 | raise RegisterError('Email domain is not allowed to register') 338 | if not self.validator.validate_username(new_username): 339 | raise RegisterError('Username is not valid') 340 | if not self.validator.validate_length(new_password, 1) \ 341 | or not self.validator.validate_length(new_password_repeat, 1): 342 | raise RegisterError('Password/repeat password fields cannot be empty') 343 | if new_password != new_password_repeat: 344 | raise RegisterError('Passwords do not match') 345 | if password_hint and not self.validator.validate_length(password_hint, 1): 346 | raise RegisterError('Password hint cannot be empty') 347 | if not self.validator.validate_password(new_password): 348 | raise RegisterError(self.validator.diagnose_password(new_password)) 349 | if roles and not isinstance(roles, list): 350 | raise LoginError('Roles must be provided as a list') 351 | if captcha: 352 | if not entered_captcha: 353 | raise RegisterError('Captcha not entered') 354 | entered_captcha = entered_captcha.strip() 355 | self._check_captcha('register_user_captcha', RegisterError, entered_captcha) 356 | return self.authentication_model.register_user(new_first_name, new_last_name, new_email, 357 | new_username, new_password, password_hint, 358 | pre_authorized, roles, callback) 359 | def reset_password(self, username: str, password: str, new_password: str, 360 | new_password_repeat: str, callback: Optional[Callable] = None) -> bool: 361 | """ 362 | Handles user password reset requests. 363 | 364 | Parameters 365 | ---------- 366 | username : str 367 | Username of the user. 368 | password : str 369 | Current password of the user. 370 | new_password : str 371 | New password of the user. 372 | new_password_repeat : str 373 | Repeated new password of the user. 374 | callback : Callable, optional 375 | Callback function that will be invoked on form submission. 376 | 377 | Returns 378 | ------- 379 | bool 380 | State of resetting the password, 381 | True: password reset successfully. 382 | """ 383 | username = username.lower().strip() 384 | if not self.validator.validate_length(new_password, 1): 385 | raise ResetError('No new password provided') 386 | if new_password != new_password_repeat: 387 | raise ResetError('Passwords do not match') 388 | if password == new_password: 389 | raise ResetError('New and current passwords are the same') 390 | if not self.validator.validate_password(new_password): 391 | raise ResetError(self.validator.diagnose_password(new_password)) 392 | return self.authentication_model.reset_password(username, password, new_password, 393 | callback) 394 | def send_password(self, result: Tuple[Optional[str], Optional[str], Optional[str]]) -> bool: 395 | """ 396 | Sends a newly generated password to the user via email. 397 | 398 | Parameters 399 | ---------- 400 | result : tuple 401 | Tuple containing (username, email, new password). 402 | 403 | Returns 404 | ------- 405 | bool 406 | True if password email was sent successfully, False otherwise. 407 | """ 408 | return self.authentication_model.send_email('PWD', result[1], result[2]) 409 | def send_username(self, result: Tuple[Optional[str], Optional[str]]) -> bool: 410 | """ 411 | Sends the retrieved username to the user via email. 412 | 413 | Parameters 414 | ---------- 415 | result : tuple 416 | Tuple containing (username, email). 417 | 418 | Returns 419 | ------- 420 | bool 421 | True if username email was sent successfully, False otherwise. 422 | """ 423 | return self.authentication_model.send_email('USERNAME', result[1], result[0]) 424 | def update_user_details(self, username: str, field: str, new_value: str, 425 | callback: Optional[Callable] = None) -> bool: 426 | """ 427 | Updates user details such as name or email. 428 | 429 | Parameters 430 | ---------- 431 | username : str 432 | Username of the user. 433 | field : str 434 | Field to update (e.g., 'email', 'first_name', 'last_name'). 435 | new_value : str 436 | New value for the specified field. 437 | callback : Callable, optional 438 | Function to be executed upon successful update. 439 | 440 | Returns 441 | ------- 442 | bool 443 | True if update is successful, False otherwise. 444 | """ 445 | username = username.lower().strip() 446 | if field == 'first_name' and not self.validator.validate_name(new_value): 447 | raise UpdateError('First name is not valid') 448 | if field == 'last_name' and not self.validator.validate_name(new_value): 449 | raise UpdateError('Last name is not valid') 450 | if field == 'email' and not self.validator.validate_email(new_value): 451 | raise UpdateError('Email is not valid') 452 | return self.authentication_model.update_user_details(username, field, new_value, 453 | callback) 454 | -------------------------------------------------------------------------------- /streamlit_authenticator/controllers/cookie_controller.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script description: This module controls requests made to the CookieModel for password-less 3 | re-authentication. 4 | 5 | Libraries imported: 6 | - typing: Provides standard type hints for Python functions. 7 | """ 8 | 9 | from typing import Any, Dict, Optional 10 | 11 | from ..models import CookieModel 12 | 13 | 14 | class CookieController: 15 | """ 16 | Controls all requests made to the CookieModel for password-less re-authentication, 17 | including deleting, retrieving, and setting cookies. 18 | """ 19 | def __init__( 20 | self, 21 | cookie_name: Optional[str] = None, 22 | cookie_key: Optional[str] = None, 23 | cookie_expiry_days: Optional[float] = None, 24 | path: Optional[str] = None 25 | ) -> None: 26 | """ 27 | Initializes the CookieController instance. 28 | 29 | Parameters 30 | ---------- 31 | cookie_name : str, optional 32 | Name of the cookie stored in the client's browser for password-less re-authentication. 33 | cookie_key : str, optional 34 | Secret key used for signing and verifying the authentication cookie. 35 | cookie_expiry_days : float, optional 36 | Number of days before the re-authentication cookie automatically expires. 37 | path : str, optional 38 | Path to the configuration file. 39 | """ 40 | self.cookie_model = CookieModel(cookie_name, 41 | cookie_key, 42 | cookie_expiry_days, 43 | path) 44 | def delete_cookie(self) -> None: 45 | """ 46 | Deletes the re-authentication cookie from the user's browser. 47 | """ 48 | self.cookie_model.delete_cookie() 49 | def get_cookie(self) -> Optional[Dict[str, Any]]: 50 | """ 51 | Retrieves the re-authentication cookie. 52 | 53 | Returns 54 | ------- 55 | dict or None 56 | If valid, returns a dictionary containing the cookie's data. 57 | Returns None if the cookie is expired or invalid. 58 | """ 59 | return self.cookie_model.get_cookie() 60 | def set_cookie(self) -> None: 61 | """ 62 | Creates and stores the re-authentication cookie in the user's browser. 63 | """ 64 | self.cookie_model.set_cookie() 65 | -------------------------------------------------------------------------------- /streamlit_authenticator/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .authentication_model import AuthenticationModel 2 | from .cookie_model import CookieModel 3 | -------------------------------------------------------------------------------- /streamlit_authenticator/models/authentication_model.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script description: This module executes the logic for authentication, including login, logout, 3 | user registration, password reset, and user modifications. 4 | 5 | Libraries imported: 6 | ------------------- 7 | - json: Handles JSON documents. 8 | - typing: Provides standard type hints for Python functions. 9 | - streamlit: Framework used for building web applications. 10 | """ 11 | 12 | import json 13 | from typing import Any, Callable, Dict, List, Literal, Optional, Tuple 14 | 15 | import streamlit as st 16 | 17 | from ..models.cloud import CloudModel 18 | from ..models.oauth2 import GoogleModel 19 | from ..models.oauth2 import MicrosoftModel 20 | from .. import params 21 | from ..utilities import (Encryptor, 22 | Hasher, 23 | Helpers, 24 | CloudError, 25 | CredentialsError, 26 | ForgotError, 27 | LoginError, 28 | RegisterError, 29 | ResetError, 30 | UpdateError, 31 | Validator) 32 | 33 | 34 | class AuthenticationModel: 35 | """ 36 | Manages user authentication, including login, logout, registration, password resets, 37 | and user details updates. 38 | """ 39 | def __init__( 40 | self, 41 | credentials: Optional[Dict[str, Any]] = None, 42 | auto_hash: bool = True, 43 | path: Optional[str] = None, 44 | api_key: Optional[str] = None, 45 | secret_key: str = 'some_key', 46 | server_url: Optional[str] = None, 47 | validator: Optional[Validator] = None 48 | ) -> None: 49 | """ 50 | Initializes the AuthenticationModel instance. 51 | 52 | Parameters 53 | ---------- 54 | credentials: dict 55 | Dictionary of usernames, names, passwords, emails, and other user data. 56 | auto_hash: bool 57 | If True, automatically hashes plain-text passwords. 58 | path: str 59 | File path of the config file. 60 | api_key: str, optional 61 | API key used to connect to the cloud server to send reset passwords and two 62 | factor authorization codes to the user by email. 63 | secret_key : str 64 | A secret key used for encryption and decryption. 65 | server_url: str, optional 66 | Cloud server URL used for cloud-related transactions. 67 | validator: Validator, optional 68 | Validator object to check username, name, and email fields. 69 | """ 70 | self.api_key = api_key 71 | self.config = self.credentials = None 72 | self.path = path 73 | if self.path: 74 | self.config = Helpers.read_config_file(path) 75 | self.credentials = self.config.get('credentials') 76 | self.api_key = self.api_key or self.config.get('api_key') 77 | else: 78 | self.credentials = credentials 79 | self.cloud_model = CloudModel(self.api_key, server_url) if self.api_key else None 80 | self.secret_key = secret_key 81 | self.validator = validator if validator is not None else Validator() 82 | if self.credentials['usernames']: 83 | self.credentials['usernames'] = { 84 | key.lower(): value 85 | for key, value in self.credentials['usernames'].items() 86 | } 87 | if auto_hash: 88 | if len(self.credentials['usernames']) > params.AUTO_HASH_MAX_USERS: 89 | print(f"""Auto hashing in progress. To avoid runtime delays, please manually 90 | pre-hash all plain text passwords in the credentials using the 91 | Hasher.hash_passwords function, and set auto_hash=False for the 92 | Authenticate class. For more information please refer to 93 | {params.AUTO_HASH_MAX_USERS_LINK}.""") 94 | for username, _ in self.credentials['usernames'].items(): 95 | if 'password' in self.credentials['usernames'][username] and \ 96 | not Hasher.is_hash(self.credentials['usernames'][username]['password']): 97 | self.credentials['usernames'][username]['password'] = \ 98 | Hasher.hash(self.credentials['usernames'][username]['password']) 99 | else: 100 | self.credentials['usernames'] = {} 101 | if 'name' not in st.session_state: 102 | st.session_state['name'] = None 103 | if 'authentication_status' not in st.session_state: 104 | st.session_state['authentication_status'] = None 105 | if 'username' not in st.session_state: 106 | st.session_state['username'] = None 107 | if 'email' not in st.session_state: 108 | st.session_state['email'] = None 109 | if 'roles' not in st.session_state: 110 | st.session_state['roles'] = None 111 | if 'logout' not in st.session_state: 112 | st.session_state['logout'] = None 113 | self.encryptor = Encryptor(self.secret_key) 114 | def check_credentials(self, username: str, password: str) -> bool: 115 | """ 116 | Checks whether the entered credentials are valid. 117 | 118 | Parameters 119 | ---------- 120 | username : str 121 | The entered username. 122 | password : str 123 | The entered password. 124 | 125 | Returns 126 | ------- 127 | bool 128 | True if credentials are valid, False otherwise. 129 | """ 130 | if username not in self.credentials['usernames']: 131 | return False 132 | try: 133 | if Hasher.check_pw(password, self.credentials['usernames'][username]['password']): 134 | return True 135 | self._record_failed_login_attempts(username) 136 | return False 137 | except (TypeError, ValueError) as e: 138 | print(f'{e} please hash all plain text passwords') 139 | return None 140 | def _count_concurrent_users(self) -> int: 141 | """ 142 | Counts the number of currently logged-in users. 143 | 144 | Returns 145 | ------- 146 | int 147 | Number of concurrently logged-in users. 148 | """ 149 | concurrent_users = 0 150 | for username, _ in self.credentials['usernames'].items(): 151 | if 'logged_in' in self.credentials['usernames'][username] and \ 152 | self.credentials['usernames'][username]['logged_in']: 153 | concurrent_users += 1 154 | return concurrent_users 155 | def _credentials_contains_value(self, value: str) -> bool: 156 | """ 157 | Checks if a value exists in the credentials dictionary. 158 | 159 | Parameters 160 | ---------- 161 | value : str 162 | The value to check. 163 | 164 | Returns 165 | ------- 166 | bool 167 | True if the value is found, False otherwise. 168 | """ 169 | return any(value in d.values() for d in self.credentials['usernames'].values()) 170 | def forgot_password(self, username: str, callback: Optional[Callable] = None 171 | ) -> Tuple[Optional[str], Optional[str], Optional[str]]: 172 | """ 173 | Generates a new random password for a user. 174 | 175 | Parameters 176 | ---------- 177 | username: str 178 | The username for which the password needs to be reset. 179 | callback: Callable, optional 180 | Function to be invoked upon form submission. 181 | 182 | Returns 183 | ------- 184 | tuple[str, str, str] or (None, None, None) 185 | The username, email, and new randomly generated password if successful, 186 | otherwise (None, None, None). 187 | """ 188 | if self._is_guest_user(username): 189 | raise ForgotError('Guest user cannot use forgot password widget') 190 | if username in self.credentials['usernames']: 191 | user = self.credentials['usernames'][username] 192 | email = user.get('email') 193 | random_password = self._set_random_password(username) 194 | if callback: 195 | callback({'widget': 'Forgot password', 'username': username, 'email': email, 196 | 'name': self._get_user_name(username), 'roles': user.get('roles'), 197 | 'random_password': random_password}) 198 | return (username, email, random_password) 199 | return False, None, None 200 | def forgot_username(self, email: str, callback: Optional[Callable]=None 201 | ) -> Tuple[Optional[str], Optional[str]]: 202 | """ 203 | Retrieves the username associated with a given email. 204 | 205 | Parameters 206 | ---------- 207 | email : str 208 | The email associated with the forgotten username. 209 | callback : Callable, optional 210 | Function to be invoked upon form submission. 211 | 212 | Returns 213 | ------- 214 | tuple[str, str] or (None, None) 215 | The username and email if found, otherwise (None, None). 216 | """ 217 | username = self._get_username('email', email) 218 | if username: 219 | user = self.credentials['usernames'][username] 220 | if callback: 221 | callback({'widget': 'Forgot username', 'username': username, 'email': email, 222 | 'name': self._get_user_name(username), 'roles': user.get('roles')}) 223 | return username, email 224 | def generate_two_factor_auth_code(self, email: str, widget: Optional[str] = None) -> str: 225 | """ 226 | Generates and sends a two-factor authentication code to the user's email. 227 | 228 | Parameters 229 | ---------- 230 | email : str 231 | Email to send two factor authentication code to. 232 | widget : str, optional 233 | Widget name to append to session state variable name. 234 | """ 235 | two_factor_auth_code = Helpers.generate_random_string(length=4, letters=False, 236 | punctuation=False) 237 | st.session_state[f'2FA_code_{widget}'] = self.encryptor.encrypt(two_factor_auth_code) 238 | self.send_email('2FA', email, two_factor_auth_code) 239 | def _get_username(self, key: str, value: str) -> Optional[str]: 240 | """ 241 | Retrieves the username associated with a given key-value pair. 242 | 243 | Parameters 244 | ---------- 245 | key : str 246 | The field name to search in (e.g., "email"). 247 | value : str 248 | The value to search for (e.g., "user@example.com"). 249 | 250 | Returns 251 | ------- 252 | str or None 253 | The associated username if found, otherwise None. 254 | """ 255 | for username, values in self.credentials['usernames'].items(): 256 | if values[key] == value: 257 | return username 258 | return False 259 | def _get_user_name(self, username: str) -> Optional[str]: 260 | """ 261 | Retrieves the full name of a user. 262 | 263 | Parameters 264 | ---------- 265 | username : str 266 | The username of the user. 267 | 268 | Returns 269 | ------- 270 | Optional[str] 271 | The full name of the user if available, otherwise None. 272 | """ 273 | user = self.credentials['usernames'][username] 274 | name = f"{user.get('first_name', '')} {user.get('last_name', '')}".strip() \ 275 | or user.get('name') 276 | return name 277 | def guest_login(self, cookie_controller: Any, provider: str = 'google', 278 | oauth2: Optional[Dict[str, Any]] = None, 279 | max_concurrent_users: Optional[int] = None, 280 | single_session: bool = False, roles: Optional[List[str]] = None, 281 | callback: Optional[Callable] = None) -> Optional[str]: 282 | """ 283 | Handles guest login via OAuth2 providers. 284 | 285 | Parameters 286 | ---------- 287 | cookie_controller : Any 288 | The cookie controller used for setting session cookies. 289 | provider : str, default='google' 290 | OAuth2 provider name (e.g., 'google' or 'microsoft'). 291 | oauth2 : dict, optional 292 | OAuth2 configuration parameters. 293 | max_concurrent_users : int, optional 294 | Maximum number of concurrent guest users allowed. 295 | single_session : bool, default=False 296 | If True, prevents multiple logins from the same user. 297 | roles : list, optional 298 | Roles assigned to the guest user. 299 | callback : Callable, optional 300 | Function to be executed after successful login. 301 | 302 | Returns 303 | ------- 304 | Optional[str] 305 | Redirect URL if authentication requires further steps, otherwise None. 306 | """ 307 | if not oauth2 and self.path: 308 | oauth2 = self.config['oauth2'] 309 | if provider.lower() == 'google': 310 | google_model = GoogleModel(oauth2[provider]) 311 | result = google_model.guest_login() 312 | elif provider.lower() == 'microsoft': 313 | microsoft_model = MicrosoftModel(oauth2[provider]) 314 | result = microsoft_model.guest_login() 315 | if isinstance(result, dict): 316 | if isinstance(max_concurrent_users, int) and self._count_concurrent_users() > \ 317 | max_concurrent_users - 1: 318 | st.query_params.clear() 319 | raise LoginError('Maximum number of concurrent users exceeded') 320 | result['email'] = result.get('email', result.get('upn')).lower() 321 | if result['email'] not in self.credentials['usernames']: 322 | self.credentials['usernames'][result['email']] = {} 323 | if not self._is_guest_user(result['email']): 324 | st.query_params.clear() 325 | raise LoginError('User already exists') 326 | self.credentials['usernames'][result['email']] = \ 327 | {'email': result['email'], 328 | 'logged_in': True, 'first_name': result.get('given_name', ''), 329 | 'last_name': result.get('family_name', ''), 330 | 'picture': result.get('picture', None), 331 | 'roles': roles} 332 | if single_session and self.credentials['usernames'][result['email']]['logged_in']: 333 | raise LoginError('Cannot log in multiple sessions') 334 | st.session_state['authentication_status'] = True 335 | st.session_state['name'] = f'{result.get("given_name", "")} ' \ 336 | f'{result.get("family_name", "")}' 337 | st.session_state['email'] = result['email'] 338 | st.session_state['username'] = result['email'] 339 | st.session_state['roles'] = roles 340 | st.query_params.clear() 341 | cookie_controller.set_cookie() 342 | if self.path: 343 | Helpers.update_config_file(self.path, 'credentials', self.credentials) 344 | if callback: 345 | callback({'widget': 'Guest login', 'email': result['email']}) 346 | return None 347 | return result 348 | def _is_guest_user(self, username : str) -> bool: 349 | """ 350 | Checks if a username is associated with a guest user. 351 | 352 | Parameters 353 | ---------- 354 | username : str 355 | Provided username. 356 | 357 | Returns 358 | ------- 359 | bool 360 | Type of user, 361 | True: guest user, 362 | False: non-guest user. 363 | """ 364 | return 'password' not in self.credentials['usernames'].get(username, {'password': None}) 365 | def login(self, username: str, password: str, max_concurrent_users: Optional[int] = None, 366 | max_login_attempts: Optional[int] = None, token: Optional[Dict[str, str]] = None, 367 | single_session: bool = False, callback: Optional[Callable] = None) -> bool: 368 | """ 369 | Executes the login by setting authentication status to true and adding the user's 370 | username and name to the session state. 371 | 372 | Parameters 373 | ---------- 374 | username : str 375 | The entered username. 376 | password : str 377 | The entered password. 378 | max_concurrent_users : int, optional 379 | Maximum number of users allowed to login concurrently. 380 | max_login_attempts : int, optional 381 | Maximum number of failed login attempts a user can make. 382 | token : dict, optional 383 | The re-authentication cookie to get the username from. 384 | single_session : bool 385 | Disables the ability for the same user to log in multiple sessions, 386 | True: single session allowed, 387 | False: multiple sessions allowed. 388 | callback : Callable, optional 389 | Callback function that will be invoked on form submission. 390 | 391 | Returns 392 | ------- 393 | bool or None 394 | True if login succeeds, False if it fails, or None if credentials are missing. 395 | """ 396 | if username: 397 | if (isinstance(max_login_attempts, int) and 398 | self.credentials['usernames'].get(username, 399 | {}).get('failed_login_attempts', 400 | 0) >= max_login_attempts): 401 | raise LoginError('Maximum number of login attempts exceeded') 402 | if self.check_credentials(username, password): 403 | if isinstance(max_concurrent_users, int) and self._count_concurrent_users() > \ 404 | max_concurrent_users - 1: 405 | raise LoginError('Maximum number of concurrent users exceeded') 406 | user = self.credentials['usernames'][username] 407 | if single_session and user.get('logged_in'): 408 | raise LoginError('Cannot log in multiple sessions') 409 | st.session_state['email'] = user.get('email') 410 | st.session_state['name'] = self._get_user_name(username) 411 | st.session_state['roles'] = user.get('roles') 412 | st.session_state['authentication_status'] = True 413 | st.session_state['username'] = username 414 | self._record_failed_login_attempts(username, reset=True) 415 | self.credentials['usernames'][username]['logged_in'] = True 416 | if 'password_hint' in st.session_state: 417 | del st.session_state['password_hint'] 418 | if self.path: 419 | Helpers.update_config_file(self.path, 'credentials', self.credentials) 420 | if callback: 421 | callback({'widget': 'Login', 'username': username, 'email': user.get('email'), 422 | 'name': self._get_user_name(username), 'roles': user.get('roles')}) 423 | return True 424 | st.session_state['authentication_status'] = False 425 | if username in self.credentials['usernames'] and 'password_hint' in \ 426 | self.credentials['usernames'][username]: 427 | user = self.credentials['usernames'][username] 428 | st.session_state['password_hint'] = user.get('password_hint') 429 | return False 430 | if token: 431 | if not token['username'] in self.credentials['usernames']: 432 | raise LoginError('User not authorized') 433 | user = self.credentials['usernames'][token['username']] 434 | st.session_state['email'] = user.get('email') 435 | st.session_state['name'] = self._get_user_name(token['username']) 436 | st.session_state['roles'] = user.get('roles') 437 | st.session_state['authentication_status'] = True 438 | st.session_state['username'] = token['username'] 439 | self.credentials['usernames'][token['username']]['logged_in'] = True 440 | if self.path: 441 | Helpers.update_config_file(self.path, 'credentials', self.credentials) 442 | return None 443 | def logout(self, callback: Optional[Callable] = None) -> None: 444 | """ 445 | Logs out the user by clearing session state variables. 446 | 447 | Parameters 448 | ---------- 449 | callback : Callable, optional 450 | Function to be invoked upon logout. 451 | """ 452 | username = st.session_state.get('username') 453 | if username and self.credentials and 'usernames' in self.credentials: 454 | if username in self.credentials['usernames']: 455 | self.credentials['usernames'][username]['logged_in'] = False 456 | if callback: 457 | callback({'widget': 'Logout', 'username': username, 458 | 'email': st.session_state.get('email'), 459 | 'name': st.session_state.get('name'), 460 | 'roles': st.session_state.get('roles') 461 | }) 462 | st.session_state['logout'] = True 463 | for key in ['name', 'username', 'authentication_status', 'email', 'roles']: 464 | st.session_state.setdefault(key, None) 465 | st.session_state[key] = None 466 | if self.path: 467 | Helpers.update_config_file(self.path, 'credentials', self.credentials) 468 | def _record_failed_login_attempts(self, username: str, reset: bool = False) -> None: 469 | """ 470 | Records the number of failed login attempts for a given username. 471 | 472 | Parameters 473 | ---------- 474 | username : str 475 | The entered username. 476 | reset : bool 477 | Reset failed login attempts option, 478 | True: number of failed login attempts for the user will be reset to 0, 479 | False: number of failed login attempts for the user will be incremented. 480 | """ 481 | if 'failed_login_attempts' not in self.credentials['usernames'][username]: 482 | self.credentials['usernames'][username]['failed_login_attempts'] = 0 483 | if reset: 484 | self.credentials['usernames'][username]['failed_login_attempts'] = 0 485 | else: 486 | self.credentials['usernames'][username]['failed_login_attempts'] += 1 487 | if self.path: 488 | Helpers.update_config_file(self.path, 'credentials', self.credentials) 489 | def _register_credentials(self, username: str, first_name: str, last_name: str, 490 | password: str, email: str, password_hint: str, 491 | roles: Optional[List[str]] = None) -> None: 492 | """ 493 | Adds the new user's information to the credentials dictionary. 494 | 495 | Parameters 496 | ---------- 497 | username : str 498 | Username of the new user. 499 | first_name : str 500 | First name of the new user. 501 | last_name : str 502 | Last name of the new user. 503 | password : str 504 | Password of the new user. 505 | email : str 506 | Email of the new user. 507 | password_hint : str 508 | Password hint for the user to remember their password. 509 | roles : list, optional 510 | User roles for registered users. 511 | """ 512 | user_data = { 513 | 'email': email, 514 | 'logged_in': False, 515 | 'first_name': first_name, 516 | 'last_name': last_name, 517 | 'password': Hasher.hash(password), 518 | 'roles': roles 519 | } 520 | if password_hint: 521 | user_data['password_hint'] = password_hint 522 | self.credentials['usernames'][username] = user_data 523 | if self.path: 524 | Helpers.update_config_file(self.path, 'credentials', self.credentials) 525 | def register_user(self, new_first_name: str, new_last_name: str, new_email: str, 526 | new_username: str, new_password: str, password_hint: str, 527 | pre_authorized: Optional[List[str]] = None, 528 | roles: Optional[List[str]] = None, 529 | callback: Optional[Callable] = None) -> Tuple[str, str, str]: 530 | """ 531 | Registers a new user's first name, last name, username, password, email, and roles. 532 | 533 | Parameters 534 | ---------- 535 | new_first_name : str 536 | First name of the new user. 537 | new_last_name : str 538 | Last name of the new user. 539 | new_email : str 540 | Email address of the new user. 541 | new_username : str 542 | Chosen username for the new user. 543 | new_password : str 544 | Password for the new user. 545 | password_hint : str 546 | A hint for remembering the password. 547 | pre_authorized : list, optional 548 | List of pre-authorized email addresses. 549 | roles : list, optional 550 | List of roles assigned to the user. 551 | callback : Callable, optional 552 | Function to be executed after successful registration. 553 | 554 | Returns 555 | ------- 556 | Tuple[str, str, str] 557 | The email, username, and full name of the registered user. 558 | """ 559 | if self._credentials_contains_value(new_email): 560 | raise RegisterError('Email already taken') 561 | if new_username in self.credentials['usernames']: 562 | raise RegisterError('Username/email already taken') 563 | if not pre_authorized and self.path: 564 | pre_authorized = self.config.get('pre-authorized', {}).get('emails', None) 565 | if isinstance(pre_authorized, list): 566 | if new_email in pre_authorized: 567 | self._register_credentials(new_username, new_first_name, new_last_name, 568 | new_password, new_email, password_hint, roles) 569 | pre_authorized.remove(new_email) 570 | if self.path: 571 | Helpers.update_config_file(self.path, 'pre-authorized', pre_authorized) 572 | if callback: 573 | callback({'widget': 'Register user', 'new_name': new_first_name, 574 | 'new_last_name': new_last_name, 'new_email': new_email, 575 | 'new_username': new_username}) 576 | return new_email, new_username, f'{new_first_name} {new_last_name}' 577 | raise RegisterError('User not pre-authorized to register') 578 | self._register_credentials(new_username, new_first_name, new_last_name, new_password, 579 | new_email, password_hint, roles) 580 | if callback: 581 | callback({'widget': 'Register user', 'new_name': new_first_name, 582 | 'new_last_name': new_last_name, 'new_email': new_email, 583 | 'new_username': new_username}) 584 | return new_email, new_username, f'{new_first_name} {new_last_name}' 585 | def reset_password(self, username: str, password: str, new_password: str, 586 | callback: Optional[Callable] = None) -> bool: 587 | """ 588 | Resets the user's password after validating the current one. 589 | 590 | Parameters 591 | ---------- 592 | username : str 593 | The username of the user. 594 | password : str 595 | The current password. 596 | new_password : str 597 | The new password to be set. 598 | callback : callable, optional 599 | Function to be invoked upon successful password reset. 600 | 601 | Returns 602 | ------- 603 | bool 604 | True if the password is reset successfully, otherwise raises an exception. 605 | """ 606 | if self._is_guest_user(username): 607 | raise ResetError('Guest user cannot reset password') 608 | if not self.check_credentials(username, password): 609 | raise CredentialsError('password') 610 | self._update_password(username, new_password) 611 | self._record_failed_login_attempts(username, reset=True) 612 | user = self.credentials['usernames'][username] 613 | if callback: 614 | callback({'widget': 'Reset password', 'username': username, 'email': user.get('email'), 615 | 'name': self._get_user_name(username), 'roles': user.get('roles')}) 616 | return True 617 | def send_email(self, email_type: Literal['2FA', 'PWD', 'USERNAME'], recipient: str, 618 | content: str) -> bool: 619 | """ 620 | Sends an email containing authentication-related information. 621 | 622 | Parameters 623 | ---------- 624 | email_type : Literal['2FA', 'PWD', 'USERNAME'] 625 | Type of email to send. 626 | - '2FA' for two-factor authentication codes. 627 | - 'PWD' for password resets. 628 | - 'USERNAME' for forgotten usernames. 629 | recipient : str 630 | Email address of the recipient. 631 | content : str 632 | Email body content. 633 | 634 | Returns 635 | ------- 636 | bool 637 | True if the email is successfully sent, False otherwise. 638 | """ 639 | if not self.api_key: 640 | raise CloudError(f"""Please provide an API key to use the two factor authentication 641 | feature. For further information please refer to 642 | {params.TWO_FACTOR_AUTH_LINK}.""") 643 | if not self.validator.validate_email(recipient): 644 | raise CloudError('Email not valid') 645 | return self.cloud_model.send_email(email_type, recipient, content) 646 | def send_password(self, result: Optional[Dict[str, Any]] = None) -> bool: 647 | """ 648 | Sends a newly generated password to the user via email. 649 | 650 | Parameters 651 | ---------- 652 | result : dict, optional 653 | Dictionary containing username, email, and generated password. 654 | 655 | Returns 656 | ------- 657 | bool 658 | True if the password was sent successfully, otherwise False. 659 | """ 660 | if not result and '2FA_content_forgot_password' in st.session_state: 661 | decrypted = self.encryptor.decrypt(st.session_state['2FA_content_forgot_password']) 662 | _, email, password = json.loads(decrypted) 663 | return self.send_email('PWD', email, password) 664 | return self.send_email('PWD', result[1], result[2]) 665 | def send_username(self, result: Optional[Dict[str, Any]] = None) -> bool: 666 | """ 667 | Sends the forgotten username to the user's email. 668 | 669 | Parameters 670 | ---------- 671 | result : dict, optional 672 | Dictionary containing the username and email. 673 | 674 | Returns 675 | ------- 676 | bool 677 | True if the username was sent successfully, otherwise False. 678 | """ 679 | if not result and '2FA_content_forgot_username' in st.session_state: 680 | decrypted = self.encryptor.decrypt(st.session_state['2FA_content_forgot_username']) 681 | username, email = json.loads(decrypted) 682 | return self.send_email('USERNAME', email, username) 683 | return self.send_email('USERNAME', result[1], result[0]) 684 | def _set_random_password(self, username: str) -> str: 685 | """ 686 | Updates the credentials dictionary with the user's hashed random password. 687 | 688 | Parameters 689 | ---------- 690 | username : str 691 | Username of the user to set the random password for. 692 | 693 | Returns 694 | ------- 695 | str 696 | New plain text password that should be transferred to the user securely. 697 | """ 698 | random_password = Helpers.generate_random_string() 699 | self.credentials['usernames'][username]['password'] = Hasher.hash(random_password) 700 | if self.path: 701 | Helpers.update_config_file(self.path, 'credentials', self.credentials) 702 | return random_password 703 | def _update_entry(self, username: str, key: str, value: str) -> None: 704 | """ 705 | Updates the credentials dictionary with the user's updated entry. 706 | 707 | Parameters 708 | ---------- 709 | username : str 710 | Username of the user to update the entry for. 711 | key : str 712 | Updated entry key i.e. "email". 713 | value : str 714 | Updated entry value i.e. "jsmith@gmail.com". 715 | """ 716 | self.credentials['usernames'][username][key] = value 717 | if self.path: 718 | Helpers.update_config_file(self.path, 'credentials', self.credentials) 719 | def _update_password(self, username: str, password: str) -> None: 720 | """ 721 | Updates the credentials dictionary with the user's hashed reset password. 722 | 723 | Parameters 724 | ---------- 725 | username : str 726 | Username of the user to update the password for. 727 | password : str 728 | Updated plain text password. 729 | """ 730 | self.credentials['usernames'][username]['password'] = Hasher.hash(password) 731 | if self.path: 732 | Helpers.update_config_file(self.path, 'credentials', self.credentials) 733 | def update_user_details(self, username: str, field: str, new_value: str, 734 | callback: Optional[Callable] = None) -> bool: 735 | """ 736 | Updates a user's name or email in the credentials database. 737 | 738 | Parameters 739 | ---------- 740 | username : str 741 | The username of the user whose details are being updated. 742 | field : str 743 | The field to be updated ('email', 'first_name', 'last_name'). 744 | new_value : str 745 | The new value for the field. 746 | callback : Callable, optional 747 | Function to be executed after updating details. 748 | 749 | Returns 750 | ------- 751 | bool 752 | True if the update was successful, otherwise raises an exception. 753 | """ 754 | user = self.credentials['usernames'][username] 755 | if field == 'email': 756 | if self._credentials_contains_value(new_value): 757 | raise UpdateError('Email already taken') 758 | if 'first_name' not in user: 759 | self.credentials['usernames'][username]['first_name'] = None 760 | self.credentials['usernames'][username]['last_name'] = None 761 | if new_value != self.credentials['usernames'][username][field]: 762 | self._update_entry(username, field, new_value) 763 | if field in {'first_name', 'last_name'}: 764 | st.session_state['name'] = self._get_user_name(username) 765 | if 'name' in user: 766 | del self.credentials['usernames'][username]['name'] 767 | if callback: 768 | callback({'widget': 'Update user details', 'username': username, 769 | 'field': field, 'new_value': new_value, 'email': user.get('email'), 770 | 'name': self._get_user_name(username), 'roles': user.get('roles')}) 771 | return True 772 | raise UpdateError('New and current values are the same') 773 | -------------------------------------------------------------------------------- /streamlit_authenticator/models/cloud/__init__.py: -------------------------------------------------------------------------------- 1 | from .cloud_model import CloudModel 2 | -------------------------------------------------------------------------------- /streamlit_authenticator/models/cloud/cloud_model.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script description: This module handles cloud-related transactions. 3 | 4 | Libraries imported: 5 | ------------------- 6 | - typing: Module implementing standard typing notations for Python functions. 7 | - json: Module used to create JSON documents. 8 | - requests: Module for making HTTP requests to the OAuth2 server. 9 | - streamlit: Framework used to build pure Python web applications. 10 | """ 11 | 12 | from typing import Literal, Optional 13 | 14 | import json 15 | import requests 16 | import streamlit as st 17 | 18 | from ... import params 19 | from ...utilities import CloudError 20 | 21 | 22 | class CloudModel: 23 | """ 24 | Handles cloud-related transactions, including retrieving remote variables 25 | and sending emails. 26 | """ 27 | def __init__( 28 | _self, 29 | api_key: str, 30 | server_url: Optional[str] = None 31 | ) -> None: 32 | """ 33 | Initializes the CloudModel instance. 34 | 35 | Parameters 36 | ---------- 37 | api_key : str 38 | API key used to connect to the cloud server. 39 | server_url : str, optional 40 | Cloud server URL used for cloud-related transactions. 41 | """ 42 | _self.api_key = api_key 43 | _self.server_url = server_url if server_url else \ 44 | _self.get_remote_variable(params.REMOTE_VARIABLES_LINK, 45 | 'TWO_FACTOR_AUTH_SERVER_ADDRESS') 46 | @st.cache_data(show_spinner=False) 47 | def get_remote_variable(_self, url: str, variable_name: str) -> str: 48 | """ 49 | Retrieves a variable stored on a remote server. 50 | 51 | Parameters 52 | ---------- 53 | url : str 54 | URL of the remote file storing variables. 55 | variable_name : str 56 | Name of the variable to retrieve. 57 | 58 | Returns 59 | ------- 60 | Optional[str] 61 | The value of the remote variable if found, otherwise None. 62 | """ 63 | try: 64 | response = requests.get(url, timeout = params.TIMEOUT) 65 | if response.status_code == 200: 66 | content = response.text 67 | variables = {} 68 | for line in content.splitlines(): 69 | if '=' in line: 70 | key, value = line.split('=', 1) 71 | variables[key.strip()] = value.strip() 72 | if variable_name in variables: 73 | return variables[variable_name] 74 | except Exception as e: 75 | print(f"""Cannot find server URL, please enter it manually into the 'Authenticate' class 76 | as server_url='{params.SERVER_URL}'""") 77 | raise CloudError(str(e)) from e 78 | def send_email(_self, email_type: Literal['2FA', 'PWD', 'USERNAME'], recipient: str, 79 | content: str) -> bool: 80 | """ 81 | Sends an email to a specified recipient. 82 | 83 | Parameters 84 | ---------- 85 | email_type : Literal['2FA', 'PWD', 'USERNAME'] 86 | Type of email to send: 87 | - '2FA': Two-factor authentication code. 88 | - 'PWD': Reset password. 89 | - 'USERNAME': Forgotten username. 90 | recipient : str 91 | Recipient's email address. 92 | content : str 93 | Email body. 94 | 95 | Returns 96 | ------- 97 | bool 98 | True if the email was sent successfully. 99 | """ 100 | try: 101 | email_data = { 102 | 'content': content, 103 | 'recipient': recipient, 104 | 'email_type': email_type 105 | } 106 | url = _self.server_url + params.SEND_EMAIL 107 | headers = {'Authorization': f'Bearer {_self.api_key}'} 108 | response = requests.post(url, headers=headers, json=email_data, timeout=params.TIMEOUT) 109 | except Exception as e: 110 | raise CloudError(str(e)) from e 111 | if 'error' in json.loads(response.text).keys(): 112 | raise CloudError(list(json.loads(response.text).values())[0]) 113 | return True 114 | -------------------------------------------------------------------------------- /streamlit_authenticator/models/cookie_model.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script description: This module handles cookie-based password-less re-authentication. 3 | 4 | Libraries imported: 5 | ------------------- 6 | - typing: Provides standard type hints for Python functions. 7 | - datetime: Handles date and time operations. 8 | - jwt: Implements JSON Web Tokens for authentication. 9 | - streamlit: Framework for building web applications. 10 | - extra_streamlit_components: Provides cookie management for Streamlit. 11 | """ 12 | 13 | from typing import Any, Dict, Optional 14 | from datetime import datetime, timedelta 15 | import jwt 16 | from jwt import DecodeError, InvalidSignatureError 17 | import streamlit as st 18 | import extra_streamlit_components as stx 19 | 20 | from ..utilities import Helpers 21 | 22 | 23 | class CookieModel: 24 | """ 25 | Manages cookie-based password-less re-authentication, including setting, retrieving, 26 | and deleting authentication cookies. 27 | """ 28 | def __init__( 29 | self, 30 | cookie_name: Optional[str] = None, 31 | cookie_key: Optional[str] = None, 32 | cookie_expiry_days: Optional[float] = None, 33 | path: Optional[str] = None 34 | ) -> None: 35 | """ 36 | Initializes the CookieModel instance. 37 | 38 | Parameters 39 | ---------- 40 | cookie_name : str, optional 41 | Name of the cookie stored in the client's browser for password-less re-authentication. 42 | cookie_key : str, optional 43 | Secret key used for signing and verifying the authentication cookie. 44 | cookie_expiry_days : float, optional 45 | Number of days before the re-authentication cookie expires. 46 | path : str, optional 47 | Path to the configuration file. 48 | """ 49 | if path: 50 | config = Helpers.read_config_file(path) 51 | self.cookie_name = config['cookie']['name'] 52 | self.cookie_key = config['cookie']['key'] 53 | self.cookie_expiry_days = config['cookie']['expiry_days'] 54 | else: 55 | self.cookie_name = cookie_name 56 | self.cookie_key = cookie_key 57 | self.cookie_expiry_days = cookie_expiry_days 58 | self.cookie_manager = stx.CookieManager() 59 | self.token = None 60 | self.exp_date = None 61 | def delete_cookie(self) -> None: 62 | """ 63 | Deletes the re-authentication cookie from the user's browser. 64 | """ 65 | try: 66 | self.cookie_manager.delete(self.cookie_name) 67 | except KeyError as e: 68 | print(e) 69 | def get_cookie(self) -> Optional[Dict[str, Any]]: 70 | """ 71 | Retrieves, validates, and returns the authentication cookie. 72 | 73 | Returns 74 | ------- 75 | dict or None 76 | If valid, returns a dictionary containing the cookie's data. 77 | Returns None if the cookie is expired or invalid. 78 | """ 79 | if st.session_state['logout']: 80 | return False 81 | # self.token = self.cookie_manager.get(self.cookie_name) 82 | self.token = st.context.cookies[self.cookie_name] if self.cookie_name in \ 83 | st.context.cookies else None 84 | if self.token is not None: 85 | self.token = self._token_decode() 86 | if (self.token is not False and 'username' in self.token and 87 | self.token['exp_date'] > datetime.now().timestamp()): 88 | return self.token 89 | return None 90 | def set_cookie(self) -> None: 91 | """ 92 | Creates and stores the authentication cookie in the user's browser. 93 | """ 94 | if self.cookie_expiry_days != 0: 95 | self.exp_date = self._set_exp_date() 96 | token = self._token_encode() 97 | self.cookie_manager.set(self.cookie_name, token, 98 | expires_at=datetime.now() + \ 99 | timedelta(days=self.cookie_expiry_days)) 100 | def _set_exp_date(self) -> float: 101 | """ 102 | Computes the expiration timestamp for the authentication cookie. 103 | 104 | Returns 105 | ------- 106 | float 107 | Unix timestamp representing the expiration date of the cookie. 108 | """ 109 | return (datetime.now() + timedelta(days=self.cookie_expiry_days)).timestamp() 110 | def _token_decode(self) -> Optional[Dict[str, Any]]: 111 | """ 112 | Decodes and verifies the JWT authentication token. 113 | 114 | Returns 115 | ------- 116 | dict or None 117 | Decoded token contents if verification is successful. 118 | Returns None if decoding fails due to an invalid signature or token error. 119 | """ 120 | try: 121 | return jwt.decode(self.token, self.cookie_key, algorithms=['HS256']) 122 | except (DecodeError, InvalidSignatureError) as e: 123 | print(e) 124 | return False 125 | def _token_encode(self) -> str: 126 | """ 127 | Encodes the authentication data into a JWT token. 128 | 129 | Returns 130 | ------- 131 | str 132 | The signed JWT token containing authentication details. 133 | """ 134 | return jwt.encode({'username': st.session_state['username'], 135 | 'exp_date': self.exp_date}, self.cookie_key, algorithm='HS256') 136 | -------------------------------------------------------------------------------- /streamlit_authenticator/models/oauth2/__init__.py: -------------------------------------------------------------------------------- 1 | from .google_model import GoogleModel 2 | from .microsoft_model import MicrosoftModel 3 | -------------------------------------------------------------------------------- /streamlit_authenticator/models/oauth2/google_model.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script description: This module handles Google OAuth2 authentication for guest login. 3 | 4 | Libraries imported: 5 | ------------------- 6 | - base64: Provides encoding/decoding functions for the PKCE security feature. 7 | - hashlib: Implements hashing for the PKCE security feature. 8 | - os: Executes system-level functions. 9 | - time: Implements sleep functions for login delays. 10 | - typing: Provides standard type hints for Python functions. 11 | - requests: Handles HTTP requests made to the OAuth2 server. 12 | - streamlit: Framework for building web applications. 13 | """ 14 | 15 | import base64 16 | import hashlib 17 | import os 18 | import time 19 | from typing import Dict, Union 20 | 21 | import requests 22 | import streamlit as st 23 | 24 | from ... import params 25 | from ...utilities import LoginError 26 | 27 | 28 | class GoogleModel: 29 | """ 30 | Handles Google OAuth2 authentication using PKCE (Proof Key for Code Exchange). 31 | """ 32 | def __init__( 33 | self, 34 | google: Dict[str, str] 35 | ) -> None: 36 | """ 37 | Initializes the GoogleModel instance. 38 | 39 | Parameters 40 | ---------- 41 | google : dict 42 | Dictionary containing Google OAuth2 configuration, including `client_id`, 43 | `redirect_uri`, and optionally `client_secret`. 44 | """ 45 | self.google = google 46 | self.code_verifier = None 47 | def generate_code_verifier(self) -> None: 48 | """ 49 | Generates a random code verifier for PKCE authentication. 50 | """ 51 | self.code_verifier = base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8').rstrip('=') 52 | def generate_code_challenge(self) -> str: 53 | """ 54 | Generates a code challenge based on the previously generated code verifier. 55 | 56 | Returns 57 | ------- 58 | str 59 | The generated code challenge. 60 | """ 61 | if self.code_verifier is None: 62 | raise LoginError('Code verifier not generated') 63 | return base64.urlsafe_b64encode( 64 | hashlib.sha256(self.code_verifier.encode('utf-8')).digest()).decode('utf-8').rstrip('=') 65 | def login_google(self) -> str: 66 | """ 67 | Constructs the Google OAuth2 authorization URL. 68 | 69 | Returns 70 | ------- 71 | str 72 | The Google OAuth2 authorization endpoint URL. 73 | """ 74 | # self.generate_code_verifier() 75 | # code_challenge = self.generate_code_challenge() 76 | google_auth_endpoint = ( 77 | f"https://accounts.google.com/o/oauth2/auth" 78 | f"?client_id={self.google['client_id']}" 79 | f"&redirect_uri={self.google['redirect_uri']}" 80 | f"&response_type=code" 81 | f"&scope=openid%20email%20profile" 82 | # f"&code_challenge={code_challenge}" 83 | # f"&code_challenge_method=S256" 84 | ) 85 | return google_auth_endpoint 86 | def get_google_user_info(self, auth_code: str) -> Dict[str, str]: 87 | """ 88 | Exchanges an authorization code for an access token and retrieves user information. 89 | 90 | Parameters 91 | ---------- 92 | auth_code : str 93 | The authorization code received from Google after user consent. 94 | 95 | Returns 96 | ------- 97 | dict 98 | Dictionary containing user information retrieved from Google. 99 | """ 100 | time.sleep(params.PRE_GUEST_LOGIN_SLEEP_TIME) 101 | if 'GoogleModel.get_google_user_info' not in st.session_state: 102 | st.session_state['GoogleModel.get_google_user_info'] = None 103 | if not st.session_state['GoogleModel.get_google_user_info']: 104 | st.session_state['GoogleModel.get_google_user_info'] = True 105 | token_url = 'https://oauth2.googleapis.com/token' 106 | token_data = { 107 | 'code': auth_code, 108 | 'client_id': self.google['client_id'], 109 | 'client_secret': self.google.get('client_secret'), 110 | 'redirect_uri': self.google['redirect_uri'], 111 | 'grant_type': 'authorization_code' 112 | # 'code_verifier': self.code_verifier 113 | } 114 | token_r = requests.post(token_url, data=token_data, timeout=10) 115 | token_json = token_r.json() 116 | if 'access_token' not in token_json: 117 | print('No access token received') 118 | st.rerun() 119 | user_info_url = 'https://www.googleapis.com/oauth2/v2/userinfo' 120 | user_info_headers = { 121 | 'Authorization': f"Bearer {token_json['access_token']}" 122 | } 123 | user_info_r = requests.get(user_info_url, headers=user_info_headers, timeout=10) 124 | if user_info_r.status_code != 200: 125 | raise LoginError('Failed to retrieve user information') 126 | return user_info_r.json() 127 | def guest_login(self) -> Union[str, Dict[str, str]]: 128 | """ 129 | Handles the login process and fetches user information or returns the authorization 130 | endpoint. 131 | 132 | Returns 133 | ------- 134 | Union[str, dict] 135 | - If not authenticated, returns the authorization endpoint URL (str). 136 | - If authenticated, returns a dictionary containing user information. 137 | """ 138 | auth_code = st.query_params.get('code') 139 | if auth_code: 140 | user_info = self.get_google_user_info(auth_code) 141 | if user_info: 142 | return user_info 143 | else: 144 | return self.login_google() 145 | return None 146 | -------------------------------------------------------------------------------- /streamlit_authenticator/models/oauth2/microsoft_model.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script description: This module handles Microsoft OAuth2 authentication for guest login. 3 | 4 | Libraries imported: 5 | ------------------- 6 | - base64: Provides encoding/decoding functions for the PKCE security feature. 7 | - hashlib: Implements hashing for the PKCE security feature. 8 | - json: Handles JSON documents for OAuth2 endpoints. 9 | - os: Executes system-level functions. 10 | - time: Implements sleep functions for login delays. 11 | - typing: Provides standard type hints for Python functions. 12 | - requests: Handles HTTP requests made to the OAuth2 server. 13 | - streamlit: Framework for building web applications. 14 | """ 15 | 16 | import base64 17 | import hashlib 18 | import json 19 | import os 20 | import time 21 | from typing import Dict, Union 22 | 23 | import requests 24 | import streamlit as st 25 | 26 | from ... import params 27 | from ...utilities import LoginError 28 | 29 | 30 | class MicrosoftModel: 31 | """ 32 | Handles Microsoft OAuth2 authentication using PKCE (Proof Key for Code Exchange). 33 | """ 34 | def __init__( 35 | self, 36 | microsoft: Dict[str, str] 37 | ) -> None: 38 | """ 39 | Initializes the MicrosoftModel instance. 40 | 41 | Parameters 42 | ---------- 43 | microsoft : dict 44 | Dictionary containing Microsoft OAuth2 configuration, including `client_id`, 45 | `tenant_id`, `redirect_uri`, and optionally `client_secret`. 46 | """ 47 | self.microsoft = microsoft 48 | self.code_verifier = None 49 | def generate_code_verifier(self) -> None: 50 | """ 51 | Generates a random code verifier for PKCE authentication. 52 | """ 53 | self.code_verifier = base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8').rstrip('=') 54 | def generate_code_challenge(self) -> str: 55 | """ 56 | Generates a code challenge based on the previously generated code verifier. 57 | 58 | Returns 59 | ------- 60 | str 61 | The generated code challenge. 62 | """ 63 | if self.code_verifier is None: 64 | raise LoginError('Code verifier not generated') 65 | return base64.urlsafe_b64encode( 66 | hashlib.sha256(self.code_verifier.encode('utf-8')).digest()).decode('utf-8').rstrip('=') 67 | def login_microsoft(self) -> str: 68 | """ 69 | Constructs the Microsoft OAuth2 authorization URL. 70 | 71 | Returns 72 | ------- 73 | str 74 | The Microsoft OAuth2 authorization endpoint URL. 75 | """ 76 | # self.generate_code_verifier() 77 | # code_challenge = self.generate_code_challenge() 78 | microsoft_auth_endpoint = ( 79 | f"https://login.microsoftonline.com/{self.microsoft['tenant_id']}/oauth2/v2.0/authorize" 80 | f"?client_id={self.microsoft['client_id']}" 81 | f"&redirect_uri={self.microsoft['redirect_uri']}" 82 | f"&response_type=code" 83 | f"&scope=openid%20profile%20email" 84 | # f"&code_challenge={code_challenge}" 85 | # f"&code_challenge_method=S256" 86 | ) 87 | return microsoft_auth_endpoint 88 | def decode_jwt(self, token: str) -> Dict[str, str]: 89 | """ 90 | Decodes a JWT token. 91 | 92 | Parameters 93 | ---------- 94 | token : str 95 | The JWT OAuth2 token. 96 | 97 | Returns 98 | ------- 99 | dict 100 | Decoded JWT payload. 101 | """ 102 | parts = token.split('.') 103 | if len(parts) != 3: 104 | raise ValueError('Invalid JWT token') 105 | decoded_payload = base64.urlsafe_b64decode(parts[1] + '==') 106 | payload_json = json.loads(decoded_payload) 107 | return payload_json 108 | def get_microsoft_user_info(self, auth_code: str) -> Dict[str, str]: 109 | """ 110 | Exchanges an authorization code for an access token and retrieves user information. 111 | 112 | Parameters 113 | ---------- 114 | auth_code : str 115 | The authorization code received from Microsoft after user consent. 116 | 117 | Returns 118 | ------- 119 | dict 120 | Dictionary containing user information retrieved from Microsoft. 121 | """ 122 | time.sleep(params.PRE_GUEST_LOGIN_SLEEP_TIME) 123 | if 'MicrosoftModel.get_microsoft_user_info' not in st.session_state: 124 | st.session_state['MicrosoftModel.get_microsoft_user_info'] = None 125 | if not st.session_state['MicrosoftModel.get_microsoft_user_info']: 126 | st.session_state['MicrosoftModel.get_microsoft_user_info'] = True 127 | base_url = 'https://login.microsoftonline.com' 128 | token_url = f"{base_url}/{self.microsoft['tenant_id']}/oauth2/v2.0/token" 129 | token_data = { 130 | 'code': auth_code, 131 | 'client_id': self.microsoft['client_id'], 132 | 'client_secret': self.microsoft.get('client_secret'), 133 | 'redirect_uri': self.microsoft['redirect_uri'], 134 | 'grant_type': 'authorization_code' 135 | # 'code_verifier': self.code_verifier 136 | } 137 | token_r = requests.post(token_url, data=token_data, timeout=10) 138 | token_json = token_r.json() 139 | if 'access_token' not in token_json: 140 | print('No access token received') 141 | st.rerun() 142 | token_json = self.decode_jwt(token_json['access_token']) 143 | keys = {'email', 'upn', 'family_name', 'given_name'} 144 | return {key: token_json[key] for key in keys if key in token_json} 145 | # user_info_url = 'https://graph.microsoft.com/v1.0/me' 146 | # user_info_headers = { 147 | # "Authorization": f"Bearer {token_json['access_token']}" 148 | # } 149 | # user_info_r = requests.get(user_info_url, headers=user_info_headers, timeout=10) 150 | # if user_info_r.status_code != 200: 151 | # raise LoginError('Failed to retrieve user information') 152 | # return user_info_r.json() 153 | def guest_login(self) -> Union[str, Dict[str, str]]: 154 | """ 155 | Handles the login process and fetches user information or returns the authorization 156 | endpoint. 157 | 158 | Returns 159 | ------- 160 | Union[str, dict] 161 | - If not authenticated, returns the authorization endpoint URL (str). 162 | - If authenticated, returns a dictionary containing user information. 163 | """ 164 | auth_code = st.query_params.get('code') 165 | if auth_code: 166 | user_info = self.get_microsoft_user_info(auth_code) 167 | if user_info: 168 | return user_info 169 | else: 170 | return self.login_microsoft() 171 | return None 172 | -------------------------------------------------------------------------------- /streamlit_authenticator/params.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration parameters and links for the Streamlit-Authenticator package. 3 | """ 4 | # GENERAL 5 | AUTO_HASH_MAX_USERS: int = 30 6 | AUTO_HASH_MAX_USERS_LINK: str = "https://github.com/mkhorasani/Streamlit-Authenticator?tab=readme-ov-file#4-setup" 7 | PASSWORD_INSTRUCTIONS: str = """ 8 | **Your password must:** 9 | - Be between 8 and 20 characters long. 10 | - Include at least one lowercase letter. 11 | - Include at least one uppercase letter. 12 | - Contain at least one digit. 13 | - Have at least one special character from !@#$%^&*()_+-=[]{};:'\"\\|,.<>/?`~. 14 | """ 15 | PRE_GUEST_LOGIN_SLEEP_TIME: float = 0.7 16 | PRE_LOGIN_SLEEP_TIME: float = 0.7 17 | REGISTER_USER_LINK: str = "https://github.com/mkhorasani/Streamlit-Authenticator?tab=readme-ov-file#authenticateregister_user" 18 | REMOTE_VARIABLES_LINK: str = "https://raw.githubusercontent.com/mkhorasani/streamlit_authenticator_variables/main/variables" 19 | TWO_FACTOR_AUTH_LINK: str = "https://github.com/mkhorasani/Streamlit-Authenticator?tab=readme-ov-file#8-enabling-two-factor-authentication" 20 | 21 | # CLOUD 22 | SEND_EMAIL: str = "/send_email" 23 | SERVER_URL: str = "https://mkhorasani.pythonanywhere.com" 24 | TIMEOUT: int = 30 25 | -------------------------------------------------------------------------------- /streamlit_authenticator/utilities/__init__.py: -------------------------------------------------------------------------------- 1 | from .exceptions import (AuthenticateError, 2 | CloudError, 3 | CredentialsError, 4 | DeprecationError, 5 | ForgotError, 6 | LoginError, 7 | LogoutError, 8 | RegisterError, 9 | ResetError, 10 | UpdateError) 11 | from .encryptor import Encryptor 12 | from .hasher import Hasher 13 | from .helpers import Helpers 14 | from .validator import Validator 15 | -------------------------------------------------------------------------------- /streamlit_authenticator/utilities/encryptor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script description: Handles encryption and decryption of plain text using AES-based encryption. 3 | 4 | Libraries Imported: 5 | ------------------- 6 | - base64: Encodes and decodes data in a URL-safe format. 7 | - hashlib: Implements hashing for security. 8 | - cryptography: Provides secure encryption and decryption. 9 | """ 10 | 11 | import base64 12 | import hashlib 13 | from cryptography.fernet import Fernet 14 | 15 | 16 | class Encryptor: 17 | """ 18 | This class provides encryption and decryption of plain text strings. 19 | """ 20 | def __init__( 21 | self, 22 | secret_key: str 23 | ) -> None: 24 | """ 25 | Initializes the Encryptor instance. 26 | 27 | Parameters 28 | ---------- 29 | secret_key : str 30 | A secret key used for encryption and decryption. 31 | """ 32 | secret_key = hashlib.sha256(secret_key.encode()).digest()[:32] 33 | secret_key = base64.urlsafe_b64encode(secret_key) 34 | self.cipher = Fernet(secret_key) 35 | def encrypt(self, string: str) -> str: 36 | """ 37 | Encrypts a plain text string. 38 | 39 | Parameters 40 | ---------- 41 | string : str 42 | The plain text string to encrypt. 43 | 44 | Returns 45 | ------- 46 | str 47 | The encrypted text as a base64-encoded string. 48 | """ 49 | return self.cipher.encrypt(string.encode()).decode() 50 | def decrypt(self, string: str) -> str: 51 | """ 52 | Decrypts an encrypted string. 53 | 54 | Parameters 55 | ---------- 56 | string : str 57 | The encrypted text as a base64-encoded string. 58 | 59 | Returns 60 | ------- 61 | str 62 | The decrypted plain text. 63 | """ 64 | return self.cipher.decrypt(string.encode()).decode() 65 | -------------------------------------------------------------------------------- /streamlit_authenticator/utilities/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script description: Handles custom exceptions for errors occurring in authentication processes, 3 | such as login failures, incorrect credentials, deprecated functionality, and 4 | issues related to password resets and user updates. 5 | """ 6 | 7 | 8 | class AuthenticateError(Exception): 9 | """ 10 | Exceptions raised in the Authenticate class. 11 | 12 | Parameters 13 | ---------- 14 | message : str 15 | The custom error message to display. 16 | """ 17 | def __init__(self, message: str) -> None: 18 | self.message = message 19 | super().__init__(self.message) 20 | 21 | 22 | class CredentialsError(Exception): 23 | """ 24 | Exception raised for incorrect credentials. 25 | 26 | Parameters 27 | ---------- 28 | credential_type : str, optional 29 | Type of credential that caused the error ('username' or 'password'). 30 | """ 31 | def __init__(self, credential_type: str='') -> None: 32 | error_message = { 33 | 'username': 'Username is incorrect', 34 | 'password': 'Password is incorrect' 35 | }.get(credential_type, 'Username/password is incorrect') 36 | 37 | super().__init__(error_message) 38 | 39 | 40 | class CloudError(Exception): 41 | """ 42 | Exception raised for cloud-related errors. 43 | 44 | Parameters 45 | ---------- 46 | message : str 47 | The custom error message to display. 48 | """ 49 | def __init__(self, message: str) -> None: 50 | super().__init__(message) 51 | self.message = message 52 | 53 | 54 | class DeprecationError(Exception): 55 | """ 56 | Exception raised for deprecated functionality. 57 | 58 | Parameters 59 | ---------- 60 | message : str 61 | The custom error message to display. 62 | """ 63 | def __init__(self, message: str) -> None: 64 | super().__init__(message) 65 | self.message = message 66 | 67 | 68 | class ForgotError(Exception): 69 | """ 70 | Exception raised for errors in the forgotten username/password process. 71 | 72 | Parameters 73 | ---------- 74 | message : str 75 | The custom error message to display. 76 | """ 77 | def __init__(self, message: str) -> None: 78 | super().__init__(message) 79 | self.message = message 80 | 81 | 82 | class LoginError(Exception): 83 | """ 84 | Exception raised for login-related errors. 85 | 86 | Parameters 87 | ---------- 88 | message : str 89 | The custom error message to display. 90 | """ 91 | def __init__(self, message: str) -> None: 92 | super().__init__(message) 93 | self.message = message 94 | 95 | 96 | class LogoutError(Exception): 97 | """ 98 | Exception raised for errors related to the logout process. 99 | 100 | Parameters 101 | ---------- 102 | message : str 103 | The custom error message to display. 104 | """ 105 | def __init__(self, message: str) -> None: 106 | super().__init__(message) 107 | self.message = message 108 | 109 | 110 | class RegisterError(Exception): 111 | """ 112 | Exception raised for errors in the user registration process. 113 | 114 | Parameters 115 | ---------- 116 | message : str 117 | The custom error message to display. 118 | """ 119 | def __init__(self, message: str) -> None: 120 | super().__init__(message) 121 | self.message = message 122 | 123 | 124 | class ResetError(Exception): 125 | """ 126 | Exception raised for errors in the password reset process. 127 | 128 | Parameters 129 | ---------- 130 | message : str 131 | The custom error message to display. 132 | """ 133 | def __init__(self, message: str) -> None: 134 | super().__init__(message) 135 | self.message = message 136 | 137 | 138 | class TwoFactorAuthError(Exception): 139 | """ 140 | Exception raised for errors in two-factor authentication. 141 | 142 | Parameters 143 | ---------- 144 | message : str 145 | The custom error message to display. 146 | """ 147 | def __init__(self, message: str) -> None: 148 | super().__init__(message) 149 | self.message = message 150 | 151 | 152 | class UpdateError(Exception): 153 | """ 154 | Exception raised for errors in updating user details. 155 | 156 | Parameters 157 | ---------- 158 | message : str 159 | The custom error message to display. 160 | """ 161 | def __init__(self, message: str) -> None: 162 | super().__init__(message) 163 | self.message = message 164 | -------------------------------------------------------------------------------- /streamlit_authenticator/utilities/hasher.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script description: Handles secure hashing and validation of plain text passwords using bcrypt. 3 | 4 | Libraries Imported: 5 | ------------------- 6 | - re: Implements regular expressions for pattern matching. 7 | - bcrypt: Provides secure password hashing. 8 | - typing: Provides standard type hints for Python functions. 9 | """ 10 | 11 | import re 12 | import bcrypt 13 | from typing import Dict, List 14 | 15 | 16 | class Hasher: 17 | """ 18 | This class provides methods for hashing and verifying passwords. 19 | """ 20 | def __init__(self) -> None: 21 | pass 22 | @classmethod 23 | def check_pw(cls, password: str, hashed_password: str) -> bool: 24 | """ 25 | Verifies if a plain text password matches a hashed password. 26 | 27 | Parameters 28 | ---------- 29 | password : str 30 | The plain text password. 31 | hashed_password : str 32 | The hashed password to compare against. 33 | 34 | Returns 35 | ------- 36 | bool 37 | True if the password matches the hash, False otherwise. 38 | """ 39 | return bcrypt.checkpw(password.encode(), hashed_password.encode()) 40 | @classmethod 41 | def hash(cls, password: str) -> str: 42 | """ 43 | Hashes a plain text password using bcrypt. 44 | 45 | Parameters 46 | ---------- 47 | password : str 48 | The plain text password. 49 | 50 | Returns 51 | ------- 52 | str 53 | The securely hashed password. 54 | """ 55 | return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() 56 | @classmethod 57 | def hash_list(cls, passwords: List[str]) -> List[str]: 58 | """ 59 | Hashes a list of plain text passwords. 60 | 61 | Parameters 62 | ---------- 63 | passwords : list of str 64 | The list of plain text passwords to be hashed. 65 | 66 | Returns 67 | ------- 68 | list of str 69 | The list of securely hashed passwords. 70 | """ 71 | return [cls.hash(password) for password in passwords] 72 | @classmethod 73 | def hash_passwords(cls, credentials: Dict[str, Dict[str, str]]) -> Dict[str, Dict[str, str]]: 74 | """ 75 | Hashes all plain text passwords in a credentials dictionary. 76 | 77 | Parameters 78 | ---------- 79 | credentials : dict 80 | Dictionary containing usernames as keys and user details as values. 81 | 82 | Returns 83 | ------- 84 | dict 85 | The credentials dictionary with all passwords securely hashed. 86 | """ 87 | usernames = credentials['usernames'] 88 | 89 | for _, user in usernames.items(): 90 | password = user['password'] 91 | if not cls.is_hash(password): 92 | hashed_password = cls.hash(password) 93 | user['password'] = hashed_password 94 | return credentials 95 | @classmethod 96 | def is_hash(cls, hash_string: str) -> bool: 97 | """ 98 | Determines if a given string is a bcrypt hash. 99 | 100 | Parameters 101 | ---------- 102 | hash_string : str 103 | The string to check. 104 | 105 | Returns 106 | ------- 107 | bool 108 | True if the string is a valid bcrypt hash, False otherwise. 109 | """ 110 | bcrypt_regex = re.compile(r'^\$2[aby]\$\d+\$.{53}$') 111 | return bool(bcrypt_regex.match(hash_string)) 112 | -------------------------------------------------------------------------------- /streamlit_authenticator/utilities/helpers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script description: This module provides miscellaneous utility functions for authentication and configuration. 3 | 4 | Libraries Imported: 5 | ------------------- 6 | - yaml: Handles data serialization for human-readable configuration files. 7 | - string: Provides support for ASCII character encoding. 8 | - random: Generates random characters. 9 | - streamlit: Framework used to build web applications. 10 | - captcha: Generates captcha images. 11 | """ 12 | 13 | import yaml 14 | from yaml.loader import SafeLoader 15 | import string 16 | import random 17 | import streamlit as st 18 | from captcha.image import ImageCaptcha 19 | 20 | from ..utilities import Encryptor 21 | 22 | 23 | class Helpers: 24 | """ 25 | This class provides various helper functions for authentication and configuration handling. 26 | """ 27 | def __init__(self) -> None: 28 | pass 29 | @classmethod 30 | def check_captcha(cls, captcha_name: str, entered_captcha: str, secret_key: str): 31 | """ 32 | Checks the validity of the entered captcha. 33 | 34 | Parameters 35 | ---------- 36 | captcha_name : str 37 | Name of the generated captcha stored in the session state. 38 | entered_captcha : str 39 | User-entered captcha to validate against the stored captcha. 40 | secret_key : str 41 | A secret key used for encryption and decryption. 42 | 43 | Returns 44 | ------- 45 | bool 46 | True if the entered captcha is valid, False otherwise. 47 | """ 48 | encryptor = Encryptor(secret_key) 49 | if entered_captcha == encryptor.decrypt(st.session_state[captcha_name]): 50 | return True 51 | return False 52 | @classmethod 53 | def generate_captcha(cls, captcha_name: str, secret_key: str) -> ImageCaptcha: 54 | """ 55 | Generates a captcha image and stores the associated captcha string in session state. 56 | 57 | Parameters 58 | ---------- 59 | captcha_name : str 60 | Name of the generated captcha stored in the session state. 61 | secret_key : str 62 | A secret key used for encryption and decryption. 63 | 64 | Returns 65 | ------- 66 | ImageCaptcha 67 | The generated captcha image. 68 | """ 69 | encryptor = Encryptor(secret_key) 70 | image = ImageCaptcha(width=120, height=75) 71 | if captcha_name not in st.session_state: 72 | st.session_state[captcha_name] = encryptor.encrypt(''.join(random.choices(string.digits, 73 | k=4))) 74 | return image.generate(encryptor.decrypt(st.session_state[captcha_name])) 75 | @classmethod 76 | def generate_random_string(cls, length: int=16, letters: bool=True, digits: bool=True, 77 | punctuation: bool=True) -> str: 78 | """ 79 | Generates a random string with optional character sets. 80 | 81 | Parameters 82 | ---------- 83 | length : int, default=16 84 | Length of the generated string. 85 | letters : bool, default=True 86 | If True, includes uppercase and lowercase letters. 87 | digits : bool, default=True 88 | If True, includes numerical digits. 89 | punctuation : bool, default=True 90 | If True, includes punctuation symbols. 91 | 92 | Returns 93 | ------- 94 | str 95 | A randomly generated string. 96 | """ 97 | letters = (string.ascii_letters if letters else '') + \ 98 | (string.digits if digits else '') + \ 99 | (''.join(c for c in string.punctuation if c not in "<>") if punctuation else '') 100 | return ''.join(random.choice(letters) for i in range(length)).replace(' ','') 101 | #@st.cache 102 | @classmethod 103 | def read_config_file(cls, path: str) -> dict: 104 | """ 105 | Reads a configuration file in YAML format. 106 | 107 | Parameters 108 | ---------- 109 | path : str 110 | File path of the configuration file. 111 | 112 | Returns 113 | ------- 114 | dict 115 | Parsed YAML configuration. 116 | """ 117 | with open(path, 'r', encoding='utf-8') as file: 118 | return yaml.load(file, Loader=SafeLoader) 119 | @classmethod 120 | def write_config_file(cls, path: str, config: dict) -> None: 121 | """ 122 | Writes a configuration dictionary to a YAML file. 123 | 124 | Parameters 125 | ---------- 126 | path : str 127 | File path of the configuration file. 128 | config : dict 129 | Configuration data to write. 130 | """ 131 | with open(path, 'w', encoding='utf-8') as file: 132 | yaml.dump(config, file, default_flow_style=False, allow_unicode=True) 133 | @classmethod 134 | def update_config_file(cls, path: str, key: str, items: dict) -> None: 135 | """ 136 | Updates a specific key in a YAML configuration file. 137 | 138 | Parameters 139 | ---------- 140 | path : str 141 | File path of the configuration file. 142 | key : str 143 | The key to update in the configuration. 144 | items : dict 145 | The new values to set for the key. 146 | """ 147 | with open(path, 'r', encoding='utf-8') as file: 148 | config = yaml.load(file, Loader=SafeLoader) 149 | config[key] = items 150 | with open(path, 'w', encoding='utf-8') as file: 151 | yaml.dump(config, file, default_flow_style=False, allow_unicode=True) 152 | -------------------------------------------------------------------------------- /streamlit_authenticator/utilities/validator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script description: This script provides string validations for various user inputs. 3 | 4 | Libraries Imported: 5 | ------------------- 6 | - re: Implements regular expressions for pattern matching. 7 | """ 8 | 9 | import re 10 | 11 | 12 | class Validator: 13 | """ 14 | This class provides validation methods for usernames, names, emails, and passwords. 15 | """ 16 | def __init__(self): 17 | pass 18 | def diagnose_password(self, password: str) -> str: 19 | """ 20 | Diagnoses the validity of the entered password and returns an error message if invalid. 21 | 22 | Parameters 23 | ---------- 24 | password : str 25 | The password to be diagnosed. 26 | 27 | Returns 28 | ------- 29 | str 30 | Error message listing password requirements that were not met. 31 | """ 32 | min_length = 8 33 | max_length = 20 34 | errors = [] 35 | if not min_length <= len(password) <= max_length: 36 | errors.append(f'Between {min_length} and {max_length} characters long \n\n') 37 | if not re.search(r'[a-z]', password): 38 | errors.append('Contain at least one lowercase letter \n\n') 39 | if not re.search(r'[A-Z]', password): 40 | errors.append('Contain at least one uppercase letter \n\n') 41 | if not re.search(r'\d', password): 42 | errors.append('Contain at least one digit \n\n') 43 | if not re.search(r'[!@#$%^&*()_+\-=\[\]{};:\'\"\\|,.<>\/?`~]', password): 44 | errors.append('Contain at least one special character (@$!%*?&) \n\n') 45 | return '**Password must:** \n\n' + ''.join(errors) 46 | def validate_email(self, email: str) -> bool: 47 | """ 48 | Checks the validity of the entered email. 49 | 50 | Parameters 51 | ---------- 52 | email : str 53 | The email to be validated. 54 | 55 | Returns 56 | ------- 57 | bool 58 | True if the email is valid, False otherwise. 59 | """ 60 | pattern = r"^[a-zA-Z0-9._%+-]{1,254}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,63}$" 61 | return bool(re.match(pattern, email)) 62 | def validate_length(self, variable: str, min_length: int=0, max_length: int=254) -> bool: 63 | """ 64 | Checks if a given string is within the specified length range. 65 | 66 | Parameters 67 | ---------- 68 | variable : str 69 | The string to be validated. 70 | min_length : int, default=0 71 | The minimum required length for the string. 72 | max_length : int, default=254 73 | The maximum allowed length for the string. 74 | 75 | Returns 76 | ------- 77 | bool 78 | True if the string length is within range, False otherwise. 79 | """ 80 | pattern = rf"^.{{{min_length},{max_length}}}$" 81 | return bool(re.match(pattern, variable)) 82 | def validate_name(self, name: str) -> bool: 83 | """ 84 | Checks the validity of the entered name. 85 | 86 | Parameters 87 | ---------- 88 | name : str 89 | The name to be validated. 90 | 91 | Returns 92 | ------- 93 | bool 94 | True if the name is valid, False otherwise. 95 | """ 96 | pattern = r"^[A-Za-z\u00C0-\u024F\u0370-\u1FFF\u2C00-\uD7FF\u4E00-\u9FFF' .-]{2,100}$" 97 | return bool(re.match(pattern, name, re.UNICODE)) 98 | def validate_password(self, password: str) -> bool: 99 | """ 100 | Checks the validity of the entered password. 101 | 102 | Parameters 103 | ---------- 104 | password : str 105 | The password to be validated. 106 | 107 | Returns 108 | ------- 109 | bool 110 | True if the password is valid, False otherwise. 111 | """ 112 | pattern = r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':\"\\|,.<>\/?`~])[A-Za-z\d!@#$%^&*()_+\-=\[\]{};':\"\\|,.<>\/?`~]{8,20}$" 113 | return bool(re.match(pattern, password)) 114 | def validate_username(self, username: str) -> bool: 115 | """ 116 | Checks the validity of the entered username. 117 | 118 | Parameters 119 | ---------- 120 | username : str 121 | The username to be validated. 122 | 123 | Returns 124 | ------- 125 | bool 126 | True if the username is valid, False otherwise. 127 | """ 128 | pattern = r"^([a-zA-Z0-9_-]{1,20}|[a-zA-Z0-9._%+-]{1,254}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,63})$" 129 | return bool(re.match(pattern, username)) 130 | -------------------------------------------------------------------------------- /streamlit_authenticator/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .authentication_view import Authenticate 2 | -------------------------------------------------------------------------------- /streamlit_authenticator/views/authentication_view.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script description: This module renders the login, logout, register user, reset password, 3 | forgot password, forgot username, and modify user details widgets. 4 | 5 | Libraries imported: 6 | ------------------- 7 | - json: Handles JSON documents. 8 | - time: Implements sleep function. 9 | - typing: Implements standard typing notations for Python functions. 10 | - streamlit: Framework used to build pure Python web applications. 11 | """ 12 | 13 | import json 14 | import time 15 | from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union 16 | 17 | import streamlit as st 18 | 19 | from ..controllers import AuthenticationController, CookieController 20 | from .. import params 21 | from ..utilities import (DeprecationError, 22 | Encryptor, 23 | Helpers, 24 | LogoutError, 25 | ResetError, 26 | UpdateError, 27 | Validator) 28 | 29 | 30 | class Authenticate: 31 | """ 32 | This class renders login, logout, register user, reset password, forgot password, 33 | forgot username, and modify user details widgets. 34 | """ 35 | def __init__( 36 | self, 37 | credentials: Union[Dict[str, Any], str], 38 | cookie_name: str = 'some_cookie_name', 39 | cookie_key: str = 'some_key', 40 | cookie_expiry_days: float = 30.0, 41 | validator: Optional[Validator] = None, 42 | auto_hash: bool = True, 43 | api_key: Optional[str] = None, 44 | **kwargs: Optional[Dict[str, Any]] 45 | ) -> None: 46 | """ 47 | Initializes an instance of Authenticate. 48 | 49 | Parameters 50 | ---------- 51 | credentials : dict or str 52 | Dictionary of user credentials or path to a configuration file. 53 | cookie_name : str, default='some_cookie_name' 54 | Name of the re-authentication cookie stored in the client's browser. 55 | cookie_key : str, default='some_key' 56 | Secret key used for encrypting the re-authentication cookie. 57 | cookie_expiry_days : float, default=30.0 58 | Expiry time for the re-authentication cookie in days. 59 | validator : Validator, optional 60 | Validator object for checking username, name, and email validity. 61 | auto_hash : bool, default=True 62 | If True, passwords will be automatically hashed. 63 | api_key : str, optional 64 | API key for sending password reset and authentication emails. 65 | **kwargs : dict, optional 66 | Additional keyword arguments. 67 | """ 68 | self.api_key = api_key 69 | self.attrs = kwargs 70 | self.secret_key = cookie_key 71 | if isinstance(validator, dict): 72 | raise DeprecationError(f"""Please note that the 'pre_authorized' parameter has been 73 | removed from the Authenticate class and added directly to the 74 | 'register_user' function. For further information please refer to 75 | {params.REGISTER_USER_LINK}.""") 76 | self.path = credentials if isinstance(credentials, str) else None 77 | self.cookie_controller = CookieController(cookie_name, 78 | cookie_key, 79 | cookie_expiry_days, 80 | self.path) 81 | self.authentication_controller = AuthenticationController(credentials, 82 | validator, 83 | auto_hash, 84 | self.path, 85 | self.api_key, 86 | self.secret_key, 87 | self.attrs.get('server_url')) 88 | self.encryptor = Encryptor(self.secret_key) 89 | def forgot_password(self, location: Literal['main', 'sidebar'] = 'main', 90 | fields: Optional[Dict[str, str]] = None, captcha: bool = False, 91 | send_email: bool = False, two_factor_auth: bool = False, 92 | clear_on_submit: bool = False, key: str = 'Forgot password', 93 | callback: Optional[Callable] = None 94 | ) -> Tuple[Optional[str], Optional[str], Optional[str]]: 95 | """ 96 | Renders a forgot password widget. 97 | 98 | Parameters 99 | ---------- 100 | location : {'main', 'sidebar'}, default='main' 101 | Location of the forgot password widget. 102 | fields : dict, optional 103 | Custom labels for form fields and buttons. 104 | captcha : bool, default=False 105 | If True, requires captcha validation. 106 | send_email : bool, default=False 107 | If True, sends the new password to the user's email. 108 | two_factor_auth : bool, default=False 109 | If True, enables two-factor authentication. 110 | clear_on_submit : bool, default=False 111 | If True, clears input fields after form submission. 112 | key : str, default='Forgot password' 113 | Unique key for the widget to prevent duplicate WidgetID errors. 114 | callback : Callable, optional 115 | Function to be executed after form submission. 116 | 117 | Returns 118 | ------- 119 | tuple[str, str, str] or (None, None, None) 120 | - Username associated with the forgotten password. 121 | - Email associated with the forgotten password. 122 | - New plain-text password to be securely transferred to the user. 123 | """ 124 | if fields is None: 125 | fields = {'Form name':'Forgot password', 'Username':'Username', 'Captcha':'Captcha', 126 | 'Submit':'Submit', 'Dialog name':'Verification code', 'Code':'Code', 127 | 'Error':'Code is incorrect'} 128 | if location not in ['main', 'sidebar']: 129 | raise ValueError("Location must be one of 'main' or 'sidebar'") 130 | if location == 'main': 131 | forgot_password_form = st.form(key=key, clear_on_submit=clear_on_submit) 132 | elif location == 'sidebar': 133 | forgot_password_form = st.sidebar.form(key=key, clear_on_submit=clear_on_submit) 134 | forgot_password_form.subheader(fields.get('Form name', 'Forgot password')) 135 | username = forgot_password_form.text_input(fields.get('Username', 'Username'), 136 | autocomplete='off') 137 | entered_captcha = None 138 | if captcha: 139 | entered_captcha = forgot_password_form.text_input(fields.get('Captcha', 'Captcha'), 140 | autocomplete='off') 141 | forgot_password_form.image(Helpers.generate_captcha('forgot_password_captcha', 142 | self.secret_key)) 143 | result = (None, None, None) 144 | if forgot_password_form.form_submit_button(fields.get('Submit', 'Submit')): 145 | result = self.authentication_controller.forgot_password(username, callback, captcha, 146 | entered_captcha) 147 | if not two_factor_auth: 148 | if send_email: 149 | self.authentication_controller.send_password(result) 150 | return result 151 | self.__two_factor_auth(result[1], result, widget='forgot_password', fields=fields) 152 | if two_factor_auth and st.session_state.get('2FA_check_forgot_password'): 153 | decrypted = self.encryptor.decrypt(st.session_state['2FA_content_forgot_password']) 154 | result = json.loads(decrypted) 155 | if send_email: 156 | self.authentication_controller.send_password(result) 157 | del st.session_state['2FA_check_forgot_password'] 158 | return result 159 | return None, None, None 160 | def forgot_username(self, location: Literal['main', 'sidebar'] = 'main', 161 | fields: Optional[Dict[str, str]] = None, captcha: bool = False, 162 | send_email: bool = False, two_factor_auth: bool = False, 163 | clear_on_submit: bool = False, key: str = 'Forgot username', 164 | callback: Optional[Callable]=None) -> Tuple[Optional[str], Optional[str]]: 165 | """ 166 | Renders a forgot username widget. 167 | 168 | Parameters 169 | ---------- 170 | location : {'main', 'sidebar'}, default='main' 171 | Location of the forgot username widget. 172 | fields : dict, optional 173 | Custom labels for form fields and buttons. 174 | captcha : bool, default=False 175 | If True, requires captcha validation. 176 | send_email : bool, default=False 177 | If True, sends the retrieved username to the user's email. 178 | two_factor_auth : bool, default=False 179 | If True, enables two-factor authentication. 180 | clear_on_submit : bool, default=False 181 | If True, clears input fields after form submission. 182 | key : str, default='Forgot username' 183 | Unique key for the widget to prevent duplicate WidgetID errors. 184 | callback : Callable, optional 185 | Function to be executed after form submission. 186 | 187 | Returns 188 | ------- 189 | tuple[str, str] or (None, str) 190 | - Username associated with the forgotten username. 191 | - Email associated with the forgotten username. 192 | """ 193 | if fields is None: 194 | fields = {'Form name':'Forgot username', 'Email':'Email', 'Captcha':'Captcha', 195 | 'Submit':'Submit', 'Dialog name':'Verification code', 'Code':'Code', 196 | 'Error':'Code is incorrect'} 197 | if location not in ['main', 'sidebar']: 198 | raise ValueError("Location must be one of 'main' or 'sidebar'") 199 | if location == 'main': 200 | forgot_username_form = st.form(key=key, clear_on_submit=clear_on_submit) 201 | elif location == 'sidebar': 202 | forgot_username_form = st.sidebar.form(key=key, clear_on_submit=clear_on_submit) 203 | forgot_username_form.subheader('Forgot username' if 'Form name' not in fields 204 | else fields['Form name']) 205 | email = forgot_username_form.text_input('Email' if 'Email' not in fields 206 | else fields['Email'], autocomplete='off') 207 | entered_captcha = None 208 | if captcha: 209 | entered_captcha = forgot_username_form.text_input('Captcha' if 'Captcha' not in fields 210 | else fields['Captcha'], 211 | autocomplete='off') 212 | forgot_username_form.image(Helpers.generate_captcha('forgot_username_captcha', 213 | self.secret_key)) 214 | if forgot_username_form.form_submit_button('Submit' if 'Submit' not in fields 215 | else fields['Submit']): 216 | result = self.authentication_controller.forgot_username(email, callback, 217 | captcha, entered_captcha) 218 | if not two_factor_auth: 219 | if send_email: 220 | self.authentication_controller.send_username(result) 221 | return result 222 | self.__two_factor_auth(email, result, widget='forgot_username', fields=fields) 223 | if two_factor_auth and st.session_state.get('2FA_check_forgot_username'): 224 | decrypted = self.encryptor.decrypt(st.session_state['2FA_content_forgot_username']) 225 | result = json.loads(decrypted) 226 | if send_email: 227 | self.authentication_controller.send_username(result) 228 | del st.session_state['2FA_check_forgot_username'] 229 | return result 230 | return None, email 231 | def experimental_guest_login(self, button_name: str='Guest login', 232 | location: Literal['main', 'sidebar'] = 'main', 233 | provider: Literal['google', 'microsoft'] = 'google', 234 | oauth2: Optional[Dict[str, Any]] = None, 235 | max_concurrent_users: Optional[int]=None, 236 | single_session: bool=False, roles: Optional[List[str]]=None, 237 | use_container_width: bool=False, 238 | callback: Optional[Callable]=None) -> None: 239 | """ 240 | Renders a guest login button. 241 | 242 | Parameters 243 | ---------- 244 | button_name : str, default='Guest login' 245 | Display name for the guest login button. 246 | location : {'main', 'sidebar'}, default='main' 247 | Location where the guest login button is rendered. 248 | provider : {'google', 'microsoft'}, default='google' 249 | OAuth2 provider used for authentication. 250 | oauth2 : dict, optional 251 | Configuration parameters for OAuth2 authentication. 252 | max_concurrent_users : int, optional 253 | Maximum number of users allowed to log in concurrently. 254 | single_session : bool, default=False 255 | If True, prevents users from logging into multiple sessions simultaneously. 256 | roles : list of str, optional 257 | Roles assigned to guest users. 258 | use_container_width : bool, default=False 259 | If True, the button width matches the container. 260 | callback : Callable, optional 261 | Function to execute when the button is pressed. 262 | """ 263 | if location not in ['main', 'sidebar']: 264 | raise ValueError("Location must be one of 'main' or 'sidebar'") 265 | if provider not in ['google', 'microsoft']: 266 | raise ValueError("Provider must be one of 'google' or 'microsoft'") 267 | if not st.session_state.get('authentication_status'): 268 | token = self.cookie_controller.get_cookie() 269 | if token: 270 | self.authentication_controller.login(token=token) 271 | time.sleep(self.attrs.get('login_sleep_time', params.PRE_LOGIN_SLEEP_TIME)) 272 | if not st.session_state.get('authentication_status'): 273 | auth_endpoint = \ 274 | self.authentication_controller.guest_login(cookie_controller=\ 275 | self.cookie_controller, 276 | provider=provider, 277 | oauth2=oauth2, 278 | max_concurrent_users=\ 279 | max_concurrent_users, 280 | single_session=single_session, 281 | roles=roles, 282 | callback=callback) 283 | if location == 'main' and auth_endpoint: 284 | st.link_button(button_name, url=auth_endpoint, 285 | use_container_width=use_container_width) 286 | if location == 'sidebar' and auth_endpoint: 287 | st.sidebar.link_button(button_name, url=auth_endpoint, 288 | use_container_width=use_container_width) 289 | def login(self, location: Literal['main', 'sidebar', 'unrendered'] = 'main', 290 | max_concurrent_users: Optional[int] = None, max_login_attempts: Optional[int] = None, 291 | fields: Optional[Dict[str, str]] = None, captcha: bool = False, 292 | single_session: bool=False, clear_on_submit: bool = False, key: str = 'Login', 293 | callback: Optional[Callable] = None 294 | ) -> Optional[Tuple[Optional[str], Optional[bool], Optional[str]]]: 295 | """ 296 | Renders a login widget. 297 | 298 | Parameters 299 | ---------- 300 | location : {'main', 'sidebar', 'unrendered'}, default='main' 301 | Location where the login widget is rendered. 302 | max_concurrent_users : int, optional 303 | Maximum number of users allowed to log in concurrently. 304 | max_login_attempts : int, optional 305 | Maximum number of failed login attempts allowed. 306 | fields : dict, optional 307 | Custom labels for form fields and buttons. 308 | captcha : bool, default=False 309 | If True, requires captcha validation. 310 | single_session : bool, default=False 311 | If True, prevents users from logging into multiple sessions simultaneously. 312 | clear_on_submit : bool, default=False 313 | If True, clears input fields after form submission. 314 | key : str, default='Login' 315 | Unique key for the widget to prevent duplicate WidgetID errors. 316 | callback : Callable, optional 317 | Function to execute when the form is submitted. 318 | 319 | Returns 320 | ------- 321 | tuple[str, bool, str] or None 322 | - If `location='unrendered'`, returns (user's name, authentication status, username). 323 | - Otherwise, returns None. 324 | """ 325 | if fields is None: 326 | fields = {'Form name':'Login', 'Username':'Username', 'Password':'Password', 327 | 'Login':'Login', 'Captcha':'Captcha'} 328 | if location not in ['main', 'sidebar', 'unrendered']: 329 | raise ValueError("Location must be one of 'main' or 'sidebar' or 'unrendered'") 330 | if not st.session_state.get('authentication_status'): 331 | token = self.cookie_controller.get_cookie() 332 | if token: 333 | self.authentication_controller.login(token=token) 334 | time.sleep(self.attrs.get('login_sleep_time', params.PRE_LOGIN_SLEEP_TIME)) 335 | if not st.session_state.get('authentication_status'): 336 | if location == 'main': 337 | login_form = st.form(key=key, clear_on_submit=clear_on_submit) 338 | elif location == 'sidebar': 339 | login_form = st.sidebar.form(key=key, clear_on_submit=clear_on_submit) 340 | elif location == 'unrendered': 341 | return (st.session_state['name'], st.session_state['authentication_status'], 342 | st.session_state['username']) 343 | login_form.subheader('Login' if 'Form name' not in fields else fields['Form name']) 344 | username = login_form.text_input('Username' if 'Username' not in fields 345 | else fields['Username'], autocomplete='off') 346 | if 'password_hint' in st.session_state: 347 | password = login_form.text_input('Password' if 'Password' not in fields 348 | else fields['Password'], type='password', 349 | help=st.session_state['password_hint'], 350 | autocomplete='off') 351 | else: 352 | password = login_form.text_input('Password' if 'Password' not in fields 353 | else fields['Password'], type='password', 354 | autocomplete='off') 355 | entered_captcha = None 356 | if captcha: 357 | entered_captcha = login_form.text_input('Captcha' if 'Captcha' not in fields 358 | else fields['Captcha'], 359 | autocomplete='off') 360 | login_form.image(Helpers.generate_captcha('login_captcha', self.secret_key)) 361 | if login_form.form_submit_button('Login' if 'Login' not in fields 362 | else fields['Login']): 363 | if self.authentication_controller.login(username, password, 364 | max_concurrent_users, 365 | max_login_attempts, 366 | single_session=single_session, 367 | callback=callback, captcha=captcha, 368 | entered_captcha=entered_captcha): 369 | self.cookie_controller.set_cookie() 370 | if self.path and self.cookie_controller.get_cookie(): 371 | st.rerun() 372 | def logout(self, button_name: str = 'Logout', 373 | location: Literal['main', 'sidebar', 'unrendered'] = 'main', 374 | key: str = 'Logout', use_container_width: bool = False, 375 | callback: Optional[Callable] = None) -> None: 376 | """ 377 | Renders a logout button. 378 | 379 | Parameters 380 | ---------- 381 | button_name : str, default='Logout' 382 | Display name for the logout button. 383 | location : {'main', 'sidebar', 'unrendered'}, default='main' 384 | Location where the logout button is rendered. 385 | key : str, default='Logout' 386 | Unique key for the widget, useful in multi-page applications. 387 | use_container_width : bool, default=False 388 | If True, the button width matches the container. 389 | callback : Callable, optional 390 | Function to execute when the button is pressed. 391 | """ 392 | if not st.session_state.get('authentication_status'): 393 | raise LogoutError('User must be logged in to use the logout button') 394 | if location not in ['main', 'sidebar', 'unrendered']: 395 | raise ValueError("Location must be one of 'main' or 'sidebar' or 'unrendered'") 396 | if location == 'main': 397 | if st.button(button_name, key=key, use_container_width=use_container_width): 398 | self.authentication_controller.logout(callback) 399 | self.cookie_controller.delete_cookie() 400 | elif location == 'sidebar': 401 | if st.sidebar.button(button_name, key=key, use_container_width=use_container_width): 402 | self.authentication_controller.logout(callback) 403 | self.cookie_controller.delete_cookie() 404 | elif location == 'unrendered': 405 | if st.session_state.get('authentication_status'): 406 | self.authentication_controller.logout() 407 | self.cookie_controller.delete_cookie() 408 | def register_user(self, location: Literal['main', 'sidebar'] = 'main', 409 | pre_authorized: Optional[List[str]] = None, 410 | domains: Optional[List[str]] = None, fields: Optional[Dict[str, str]] = None, 411 | captcha: bool = True, roles: Optional[List[str]] = None, 412 | merge_username_email: bool = False, password_hint: bool = True, 413 | two_factor_auth: bool = False, clear_on_submit: bool = False, 414 | key: str = 'Register user', callback: Optional[Callable] = None 415 | ) -> Tuple[Optional[str], Optional[str], Optional[str]]: 416 | """ 417 | Renders a register new user widget. 418 | 419 | Parameters 420 | ---------- 421 | location : {'main', 'sidebar'}, default='main' 422 | Location where the registration widget is rendered. 423 | pre_authorized : list of str, optional 424 | List of emails of unregistered users who are authorized to register. 425 | domains : list of str, optional 426 | List of allowed email domains (e.g., ['gmail.com', 'yahoo.com']). 427 | fields : dict, optional 428 | Custom labels for form fields and buttons. 429 | captcha : bool, default=True 430 | If True, requires captcha validation. 431 | roles : list of str, optional 432 | User roles for registered users. 433 | merge_username_email : bool, default=False 434 | If True, uses the email as the username. 435 | password_hint : bool, default=True 436 | If True, includes a password hint field. 437 | two_factor_auth : bool, default=False 438 | If True, enables two-factor authentication. 439 | clear_on_submit : bool, default=False 440 | If True, clears input fields after form submission. 441 | key : str, default='Register user' 442 | Unique key for the widget to prevent duplicate WidgetID errors. 443 | callback : Callable, optional 444 | Function to execute when the form is submitted. 445 | 446 | Returns 447 | ------- 448 | tuple[str, str, str] or (None, None, None) 449 | - Email associated with the new user. 450 | - Username associated with the new user. 451 | - Name associated with the new user. 452 | """ 453 | if isinstance(pre_authorized, bool) or isinstance(pre_authorized, dict): 454 | raise DeprecationError(f"""Please note that the 'pre_authorized' parameter now 455 | requires a list of pre-authorized emails. For further 456 | information please refer to {params.REGISTER_USER_LINK}.""") 457 | if fields is None: 458 | fields = {'Form name':'Register user', 'First name':'First name', 459 | 'Last name':'Last name', 'Email':'Email', 'Username':'Username', 460 | 'Password':'Password', 'Repeat password':'Repeat password', 461 | 'Password hint':'Password hint', 'Captcha':'Captcha', 'Register':'Register', 462 | 'Dialog name':'Verification code', 'Code':'Code', 'Submit':'Submit', 463 | 'Error':'Code is incorrect'} 464 | if location not in ['main', 'sidebar']: 465 | raise ValueError("Location must be one of 'main' or 'sidebar'") 466 | if location == 'main': 467 | register_user_form = st.form(key=key, clear_on_submit=clear_on_submit) 468 | elif location == 'sidebar': 469 | register_user_form = st.sidebar.form(key=key, clear_on_submit=clear_on_submit) 470 | register_user_form.subheader('Register user' if 'Form name' not in fields 471 | else fields['Form name']) 472 | col1_1, col2_1 = register_user_form.columns(2) 473 | new_first_name = col1_1.text_input('First name' if 'First name' not in fields 474 | else fields['First name'], autocomplete='off') 475 | new_last_name = col2_1.text_input('Last name' if 'Last name' not in fields 476 | else fields['Last name'], autocomplete='off') 477 | if merge_username_email: 478 | new_email = register_user_form.text_input('Email' if 'Email' not in fields 479 | else fields['Email'], autocomplete='off') 480 | new_username = new_email 481 | else: 482 | new_email = col1_1.text_input('Email' if 'Email' not in fields 483 | else fields['Email'], autocomplete='off') 484 | new_username = col2_1.text_input('Username' if 'Username' not in fields 485 | else fields['Username'], autocomplete='off') 486 | col1_2, col2_2 = register_user_form.columns(2) 487 | password_instructions = self.attrs.get('password_instructions', 488 | params.PASSWORD_INSTRUCTIONS) 489 | new_password = col1_2.text_input('Password' if 'Password' not in fields 490 | else fields['Password'], type='password', 491 | help=password_instructions, autocomplete='off') 492 | new_password_repeat = col2_2.text_input('Repeat password' if 'Repeat password' not in fields 493 | else fields['Repeat password'], type='password', 494 | autocomplete='off') 495 | if password_hint: 496 | password_hint = register_user_form.text_input('Password hint' if 'Password hint' not in 497 | fields else fields['Password hint'], 498 | autocomplete='off') 499 | entered_captcha = None 500 | if captcha: 501 | entered_captcha = register_user_form.text_input('Captcha' if 'Captcha' not in fields 502 | else fields['Captcha'], 503 | autocomplete='off').strip() 504 | register_user_form.image(Helpers.generate_captcha('register_user_captcha', 505 | self.secret_key)) 506 | if register_user_form.form_submit_button('Register' if 'Register' not in fields 507 | else fields['Register']): 508 | if two_factor_auth: 509 | self.__two_factor_auth(new_email, widget='register', fields=fields) 510 | else: 511 | return self.authentication_controller.register_user(new_first_name, new_last_name, 512 | new_email, new_username, 513 | new_password, 514 | new_password_repeat, 515 | password_hint, pre_authorized, 516 | domains, roles, callback, 517 | captcha, entered_captcha) 518 | if two_factor_auth and st.session_state.get('2FA_check_register'): 519 | del st.session_state['2FA_check_register'] 520 | return self.authentication_controller.register_user(new_first_name, new_last_name, 521 | new_email, new_username, 522 | new_password, new_password_repeat, 523 | password_hint, pre_authorized, 524 | domains, roles, callback, captcha, 525 | entered_captcha) 526 | return None, None, None 527 | def reset_password(self, username: str, location: Literal['main', 'sidebar'] = 'main', 528 | fields: Optional[Dict[str, str]] = None, clear_on_submit: bool = False, 529 | key: str = 'Reset password', callback: Optional[Callable] = None 530 | ) -> Optional[bool]: 531 | """ 532 | Renders a password reset widget. 533 | 534 | Parameters 535 | ---------- 536 | username : str 537 | Username of the user whose password is being reset. 538 | location : {'main', 'sidebar'}, default='main' 539 | Location where the password reset widget is rendered. 540 | fields : dict, optional 541 | Custom labels for form fields and buttons. 542 | clear_on_submit : bool, default=False 543 | If True, clears input fields after form submission. 544 | key : str, default='Reset password' 545 | Unique key for the widget to prevent duplicate WidgetID errors. 546 | callback : Callable, optional 547 | Function to execute when the form is submitted. 548 | 549 | Returns 550 | ------- 551 | bool or None 552 | - True if the password reset was successful. 553 | - None if the reset failed or was not attempted. 554 | """ 555 | if not st.session_state.get('authentication_status'): 556 | raise ResetError('User must be logged in to use the reset password widget') 557 | if fields is None: 558 | fields = {'Form name':'Reset password', 'Current password':'Current password', 559 | 'New password':'New password','Repeat password':'Repeat password', 560 | 'Reset':'Reset'} 561 | if location not in ['main', 'sidebar']: 562 | raise ValueError("Location must be one of 'main' or 'sidebar'") 563 | if location == 'main': 564 | reset_password_form = st.form(key=key, clear_on_submit=clear_on_submit) 565 | elif location == 'sidebar': 566 | reset_password_form = st.sidebar.form(key=key, clear_on_submit=clear_on_submit) 567 | reset_password_form.subheader('Reset password' if 'Form name' not in fields 568 | else fields['Form name']) 569 | password = reset_password_form.text_input('Current password' 570 | if 'Current password' not in fields 571 | else fields['Current password'], 572 | type='password', autocomplete='off').strip() 573 | password_instructions = self.attrs.get('password_instructions', 574 | params.PASSWORD_INSTRUCTIONS) 575 | new_password = reset_password_form.text_input('New password' 576 | if 'New password' not in fields 577 | else fields['New password'], 578 | type='password', 579 | help=password_instructions, 580 | autocomplete='off').strip() 581 | new_password_repeat = reset_password_form.text_input('Repeat password' 582 | if 'Repeat password' not in fields 583 | else fields['Repeat password'], 584 | type='password', 585 | autocomplete='off').strip() 586 | if reset_password_form.form_submit_button('Reset' if 'Reset' not in fields 587 | else fields['Reset']): 588 | if self.authentication_controller.reset_password(username, password, new_password, 589 | new_password_repeat, callback): 590 | return True 591 | return None 592 | def __two_factor_auth(self, email: str, content: Optional[Dict[str, Any]] = None, 593 | fields: Optional[Dict[str, str]] = None, widget: Optional[str] = None 594 | ) -> None: 595 | """ 596 | Renders a two-factor authentication widget. 597 | 598 | Parameters 599 | ---------- 600 | email : str 601 | Email address to which the two-factor authentication code is sent. 602 | content : dict, optional 603 | Optional content to save in session state. 604 | fields : dict, optional 605 | Custom labels for form fields and buttons. 606 | widget : str, optional 607 | Widget name used as a key in session state variables. 608 | """ 609 | self.authentication_controller.generate_two_factor_auth_code(email, widget) 610 | @st.dialog('Verification code' if 'Dialog name' not in fields else fields['Dialog name']) 611 | def two_factor_auth_form(): 612 | code = st.text_input('Code' if 'Code' not in fields else fields['Code'], 613 | help='Please enter the code sent to your email' 614 | if 'Instructions' not in fields else fields['Instructions'], 615 | autocomplete='off') 616 | if st.button('Submit' if 'Submit' not in fields else fields['Submit']): 617 | if self.authentication_controller.check_two_factor_auth_code(code, content, widget): 618 | st.rerun() 619 | else: 620 | st.error('Code is incorrect' if 'Error' not in fields else fields['Error']) 621 | two_factor_auth_form() 622 | def update_user_details(self, username: str, location: Literal['main', 'sidebar'] = 'main', 623 | fields: Optional[Dict[str, str]] = None, 624 | clear_on_submit: bool = False, key: str = 'Update user details', 625 | callback: Optional[Callable] = None) -> bool: 626 | """ 627 | Renders an update user details widget. 628 | 629 | Parameters 630 | ---------- 631 | username : str 632 | Username of the user whose details are being updated. 633 | location : {'main', 'sidebar'}, default='main' 634 | Location where the update user details widget is rendered. 635 | fields : dict, optional 636 | Custom labels for form fields and buttons. 637 | clear_on_submit : bool, default=False 638 | If True, clears input fields after form submission. 639 | key : str, default='Update user details' 640 | Unique key for the widget to prevent duplicate WidgetID errors. 641 | callback : Callable, optional 642 | Function to execute when the form is submitted. 643 | 644 | Returns 645 | ------- 646 | bool or None 647 | - True if user details were successfully updated. 648 | - None if the update failed or was not attempted. 649 | """ 650 | if not st.session_state.get('authentication_status'): 651 | raise UpdateError('User must be logged in to use the update user details widget') 652 | if fields is None: 653 | fields = {'Form name':'Update user details', 'Field':'Field', 'First name':'First name', 654 | 'Last name':'Last name', 'Email':'Email', 'New value':'New value', 655 | 'Update':'Update'} 656 | if location not in ['main', 'sidebar']: 657 | raise ValueError("Location must be one of 'main' or 'sidebar'") 658 | if location == 'main': 659 | update_user_details_form = st.form(key=key, clear_on_submit=clear_on_submit) 660 | elif location == 'sidebar': 661 | update_user_details_form = st.sidebar.form(key=key, clear_on_submit=clear_on_submit) 662 | update_user_details_form.subheader('Update user details' if 'Form name' not in fields 663 | else fields['Form name']) 664 | update_user_details_form_fields = ['First name' if 'First name' not in fields else \ 665 | fields['First name'], 666 | 'Last name' if 'Last name' not in fields else \ 667 | fields['Last name'], 668 | 'Email' if 'Email' not in fields else fields['Email']] 669 | field = update_user_details_form.selectbox('Field' if 'Field' not in fields 670 | else fields['Field'], 671 | update_user_details_form_fields) 672 | new_value = update_user_details_form.text_input('New value' if 'New value' not in fields 673 | else fields['New value'], 674 | autocomplete='off').strip() 675 | if update_user_details_form_fields.index(field) == 0: 676 | field = 'first_name' 677 | elif update_user_details_form_fields.index(field) == 1: 678 | field = 'last_name' 679 | elif update_user_details_form_fields.index(field) == 2: 680 | field = 'email' 681 | if update_user_details_form.form_submit_button('Update' if 'Update' not in fields 682 | else fields['Update']): 683 | if self.authentication_controller.update_user_details(username, field, new_value, 684 | callback): 685 | # self.cookie_controller.set_cookie() 686 | return True 687 | -------------------------------------------------------------------------------- /tests/app.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script description: This script imports tests the Streamlit-Authenticator package. 3 | 4 | Libraries imported: 5 | ------------------- 6 | - yaml: Module implementing the data serialization used for human readable documents. 7 | - streamlit: Framework used to build pure Python web applications. 8 | """ 9 | 10 | import yaml 11 | import streamlit as st 12 | from yaml.loader import SafeLoader 13 | import streamlit_authenticator as stauth 14 | from streamlit_authenticator.utilities import * 15 | 16 | # Loading config file 17 | with open('config.yaml', 'r', encoding='utf-8') as file: 18 | config = yaml.load(file, Loader=SafeLoader) 19 | 20 | # Pre-hashing all plain text passwords once 21 | # stauth.Hasher.hash_passwords(config['credentials']) 22 | 23 | # Creating the authenticator object 24 | authenticator = stauth.Authenticate( 25 | config['credentials'], 26 | config['cookie']['name'], 27 | config['cookie']['key'], 28 | config['cookie']['expiry_days'] 29 | ) 30 | 31 | # authenticator = stauth.Authenticate( 32 | # '../config.yaml' 33 | # ) 34 | 35 | # Creating a login widget 36 | try: 37 | authenticator.login() 38 | except LoginError as e: 39 | st.error(e) 40 | 41 | # Creating a guest login button 42 | try: 43 | authenticator.experimental_guest_login('Login with Google', provider='google', 44 | oauth2=config['oauth2']) 45 | authenticator.experimental_guest_login('Login with Microsoft', provider='microsoft', 46 | oauth2=config['oauth2']) 47 | except LoginError as e: 48 | st.error(e) 49 | 50 | # Authenticating user 51 | if st.session_state['authentication_status']: 52 | authenticator.logout() 53 | st.write(f'Welcome *{st.session_state["name"]}*') 54 | st.title('Some content') 55 | elif st.session_state['authentication_status'] is False: 56 | st.error('Username/password is incorrect') 57 | elif st.session_state['authentication_status'] is None: 58 | st.warning('Please enter your username and password') 59 | 60 | # Creating a password reset widget 61 | if st.session_state['authentication_status']: 62 | try: 63 | if authenticator.reset_password(st.session_state['username']): 64 | st.success('Password modified successfully') 65 | except (CredentialsError, ResetError) as e: 66 | st.error(e) 67 | 68 | # Creating a new user registration widget 69 | try: 70 | (email_of_registered_user, 71 | username_of_registered_user, 72 | name_of_registered_user) = authenticator.register_user() 73 | if email_of_registered_user: 74 | st.success('User registered successfully') 75 | except RegisterError as e: 76 | st.error(e) 77 | 78 | # Creating a forgot password widget 79 | try: 80 | (username_of_forgotten_password, 81 | email_of_forgotten_password, 82 | new_random_password) = authenticator.forgot_password() 83 | if username_of_forgotten_password: 84 | st.success('New password sent securely') 85 | # Random password to be transferred to the user securely 86 | elif not username_of_forgotten_password: 87 | st.error('Username not found') 88 | except ForgotError as e: 89 | st.error(e) 90 | 91 | # Creating a forgot username widget 92 | try: 93 | (username_of_forgotten_username, 94 | email_of_forgotten_username) = authenticator.forgot_username() 95 | if username_of_forgotten_username: 96 | st.success('Username sent securely') 97 | # Username to be transferred to the user securely 98 | elif not username_of_forgotten_username: 99 | st.error('Email not found') 100 | except ForgotError as e: 101 | st.error(e) 102 | 103 | # Creating an update user details widget 104 | if st.session_state['authentication_status']: 105 | try: 106 | if authenticator.update_user_details(st.session_state['username']): 107 | st.success('Entry updated successfully') 108 | except UpdateError as e: 109 | st.error(e) 110 | 111 | # Saving config file 112 | with open('config.yaml', 'w', encoding='utf-8') as file: 113 | yaml.dump(config, file, default_flow_style=False, allow_unicode=True) 114 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | Script description: This script imports tests the Streamlit-Authenticator package. 3 | 4 | Libraries imported: 5 | ------------------- 6 | - streamlit: Framework used to build pure Python web applications. 7 | """ 8 | 9 | from streamlit.testing.v1 import AppTest 10 | 11 | def test_login(): 12 | at = AppTest.from_file('tests/app.py').run() 13 | at.text_input[0].input('jsmith').run() 14 | at.text_input[1].input('abc').run() 15 | at.button[0].click().run() 16 | assert 'jsmith' in at.session_state['username'] 17 | --------------------------------------------------------------------------------