From 82fe0bd3d9b689bd0ae84495fdba2544659c1149 Mon Sep 17 00:00:00 2001 From: Mohammad Khorasani Date: Sat, 27 Jul 2024 17:20:34 +0300 Subject: [PATCH] v0.3.3 --- README.md | 128 +++-- config.yaml | 8 +- graphics/register_user.JPG | Bin 30475 -> 36343 bytes setup.py | 3 +- streamlit_authenticator/__init__.py | 44 +- .../authenticate/authentication/__init__.py | 444 ---------------- .../controllers/__init__.py | 2 + .../controllers/authentication_controller.py | 329 ++++++++++++ .../controllers/cookie_controller.py | 49 ++ streamlit_authenticator/models/__init__.py | 2 + .../models/authentication_model.py | 479 ++++++++++++++++++ .../__init__.py => models/cookie_model.py} | 53 +- streamlit_authenticator/params.py | 7 + streamlit_authenticator/utilities/__init__.py | 12 + .../utilities/exceptions.py | 26 + streamlit_authenticator/utilities/hasher.py | 36 +- streamlit_authenticator/utilities/helpers.py | 63 ++- .../utilities/validator.py | 52 +- streamlit_authenticator/views/__init__.py | 1 + .../authentication_view.py} | 378 ++++++++------ tests/streamlit_authenticator_test.py | 42 +- 21 files changed, 1416 insertions(+), 742 deletions(-) delete mode 100644 streamlit_authenticator/authenticate/authentication/__init__.py create mode 100644 streamlit_authenticator/controllers/__init__.py create mode 100644 streamlit_authenticator/controllers/authentication_controller.py create mode 100644 streamlit_authenticator/controllers/cookie_controller.py create mode 100644 streamlit_authenticator/models/__init__.py create mode 100644 streamlit_authenticator/models/authentication_model.py rename streamlit_authenticator/{authenticate/cookie/__init__.py => models/cookie_model.py} (74%) create mode 100644 streamlit_authenticator/params.py create mode 100644 streamlit_authenticator/views/__init__.py rename streamlit_authenticator/{authenticate/__init__.py => views/authentication_view.py} (50%) diff --git a/README.md b/README.md index 44981fe4..7ceb5978 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ -**A secure authentication module to validate user credentials in a Streamlit application** +**A secure authentication module to validate user credentials in a Streamlit application** [![Downloads](https://static.pepy.tech/badge/streamlit-authenticator)](https://pepy.tech/project/streamlit-authenticator) [![Downloads](https://static.pepy.tech/badge/streamlit-authenticator/month)](https://pepy.tech/project/streamlit-authenticator) @@ -23,7 +23,7 @@ pip install streamlit-authenticator ## Example -Using Streamlit-Authenticator is as simple as importing the module and calling it to verify your predefined users' credentials. +Using Streamlit-Authenticator is as simple as importing the module and calling it to verify your user's credentials. ```python import streamlit as st @@ -33,7 +33,7 @@ import streamlit_authenticator as stauth ### 1. Creating a configuration file * Initially create a YAML configuration file and define your user's credentials: including names, usernames, and passwords (plain text passwords will be hashed automatically). -* In addition, 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. +* In addition, 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. * Finally, define a list of pre-authorized emails of users who can register and add their credentials to the configuration file with the use of the **register_user** widget. * **_Please remember to update the config file (as shown in step 9) after you use the reset_password, register_user, forgot_password, or update_user_details widgets._** @@ -61,7 +61,18 @@ pre-authorized: - melsby@gmail.com ``` -_Please note that the 'logged_in' field corresponding to each user's log-in status will be added automatically._ +* 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. +* If you choose to pre-hash the passwords, please set the **auto_hash** parameter in the **Authenticate** class to False (see next section). + +> ### Hasher.hash_passwords +> #### Parameters: +> - **credentials:** _dict_ +> - The credentials dict with plain text passwords. +> #### Returns:: +> - _dict_ +> - The credentials dict with hashed passwords. + +_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 will be added and managed automatically._ ### 2. Creating a login widget @@ -94,13 +105,15 @@ authenticator = stauth.Authenticate( > - Specifies the key that will be used to hash the signature of the re-authentication cookie. > - **cookie_expiry_days:** _float, default 30.0_ > - Specifies the number of days before the re-authentication cookie automatically expires on the client's browser. -> - **pre-authorized:** _list, default None_ +> - **pre_authorized:** _list, optional, default None_ > - Provides the list of emails of unregistered users who are authorized to register. -> - **validator:** _object, default None_ +> - **validator:** _Validator, optional, default None_ > - Provides a validator object that will check the validity of the username, name, and email fields. +> - **auto_hash:** _bool, default True_ +> - Automatic hashing requirement for passwords, True: plain text passwords will be automatically hashed, False: plain text passwords will not be automatically hashed. * Then render the login module as follows. -* **_Please remember to re-invoke the login function on each and every page in a multi-page application._** +* **_Please remember to re-invoke an 'unrendered' login widget on each and every page in a multi-page application._** ```python authenticator.login() @@ -108,16 +121,24 @@ authenticator.login() > ### Authenticate.login > #### Parameters: -> - **location:** _str, {'main', 'sidebar'}, default 'main'_ +> - **location:** _str, {'main', 'sidebar', 'unrendered'}, default 'main'_ > - Specifies the location of the login widget. -> - **max_concurrent_users:** _int, default None_ -> - Limits the number of concurrent users. If not specified there will be no limit to the number of users. -> - **max_login_attempts:** _int, default None_ +> - **max_concurrent_users:** _int, optional, default None_ +> - Limits the number of concurrent users. If not specified there will be no limit to the number of concurrently logged in users. +> - **max_login_attempts:** _int, optional, default None_ > - Limits the number of failed login attempts. If not specified there will be no limit to the number of failed login attempts. -> - **fields:** _dict, default {'Form name':'Login', 'Username':'Username', 'Password':'Password', 'Login':'Login'}_ +> - **fields:** _dict, optional, default {'Form name':'Login', 'Username':'Username', 'Password':'Password', 'Login':'Login'}_ > - Customizes the text of headers, buttons and other fields. +> - **captcha:** _bool, default False_ +> - Specifies the captcha requirement for the login widget, True: captcha required, False: captcha removed. > - **clear_on_submit:** _bool, default False_ > - Specifies the clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. +> - **key:** _str, default 'Login'_ +> - Unique key provided to widget to avoid duplicate WidgetID errors. +> - **callback:** _callable, optional, default None_ +> - Optional callback function that will be invoked on form submission. +> - **sleep_time:** _float, optional, default None_ +> - Optional sleep time for the login widget. > #### Returns: > - _str_ > - Name of the authenticated user. @@ -130,18 +151,18 @@ authenticator.login() ### 3. Authenticating users -* You can then retrieve the name, authentication status, and username from Streamlit's session state using **st.session_state["name"]**, **st.session_state["authentication_status"]**, and **st.session_state["username"]** to allow a verified user to proceed to any restricted content. +* You can then retrieve the name, authentication status, and username from Streamlit's session state using **st.session_state['name']**, **st.session_state['authentication_status']**, and **st.session_state['username']** to allow a verified user to access restricted content. * 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. * The optional **key** parameter for the logout button should be used with multi-page applications to prevent Streamlit from throwing duplicate key errors. ```python -if st.session_state["authentication_status"]: +if st.session_state['authentication_status']: authenticator.logout() st.write(f'Welcome *{st.session_state["name"]}*') st.title('Some content') -elif st.session_state["authentication_status"] is False: +elif st.session_state['authentication_status'] is False: st.error('Username/password is incorrect') -elif st.session_state["authentication_status"] is None: +elif st.session_state['authentication_status'] is None: st.warning('Please enter your username and password') ``` @@ -149,10 +170,12 @@ elif st.session_state["authentication_status"] is None: > #### Parameters: > - **button_name:** _str, default 'Logout'_ > - Customizes the button name. -> - **location:** _str, {'main', 'sidebar','unrendered'}, default 'main'_ +> - **location:** _str, {'main', 'sidebar', 'unrendered'}, default 'main'_ > - Specifies the location of the logout button. If 'unrendered' is passed, the logout logic will be executed without rendering the button. > - **key:** _str, default None_ > - Unique key that should be used in multi-page applications. +> - **callback:** _callable, optional, default None_ +> - Optional callback function that will be invoked on submission. ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/logged_in.JPG) @@ -167,9 +190,9 @@ elif st.session_state["authentication_status"] is None: * You may use the **reset_password** widget to allow a logged in user to modify their password as shown below. ```python -if st.session_state["authentication_status"]: +if st.session_state['authentication_status']: try: - if authenticator.reset_password(st.session_state["username"]): + if authenticator.reset_password(st.session_state['username']): st.success('Password modified successfully') except Exception as e: st.error(e) @@ -181,21 +204,28 @@ if st.session_state["authentication_status"]: > - Specifies the username of the user to reset the password for. > - **location:** _str, {'main', 'sidebar'}, default 'main'_ > - Specifies the location of the reset password widget. -> - **fields:** _dict, default {'Form name':'Reset password', 'Current password':'Current password', 'New password':'New password', 'Repeat password': 'Repeat password', 'Reset':'Reset'}_ +> - **fields:** _dict, optional, default {'Form name':'Reset password', 'Current password':'Current password', 'New password':'New password', 'Repeat password': 'Repeat password', 'Reset':'Reset'}_ > - Customizes the text of headers, buttons and other fields. > - **clear_on_submit:** _bool, default False_ > - Specifies the clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. +> - **key:** _str, default 'Reset password'_ +> - Unique key provided to widget to avoid duplicate WidgetID errors. +> - **callback:** _callable, optional, default None_ +> - Optional callback function that will be invoked on form submission. > #### Returns:: > - _bool_ > - Status of resetting the password. ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/reset_password.JPG) -_Please remember to update the config file (as shown in step 9) after you use this widget._ +**_Please remember to update the config file (as shown in step 9) after you use this widget._** ### 5. Creating a new user registration widget -* You may use the **register_user** widget to allow a user to sign up to your application as shown below. If you require the user to be pre-authorized, set the **pre-authorization** argument to True and add their email to the **pre-authorized** list in the configuration file. Once they have registered, their email will be automatically removed from the **pre-authorized** list in the configuration file. Alternatively, to allow anyone to sign up, set the **pre-authorization** argument to False. +* You may use the **register_user** widget to allow a user to sign up to your application as shown below. +* If you require the user to be pre-authorized, set the **pre_authorization** parameter to True and add their email to the **pre_authorized** list in the configuration file. +* Once they have registered, their email will be automatically removed from the **pre_authorized** list in the configuration file. +* Alternatively, to allow anyone to sign up, set the **pre_authorization** parameter to False. ```python try: @@ -210,14 +240,20 @@ except Exception as e: > #### Parameters: > - **location:** _str, {'main', 'sidebar'}, default 'main'_ > - Specifies the location of the register user widget. -> - **pre-authorization:** _bool, default True_ +> - **pre_authorization:** _bool, default True_ > - Specifies the pre-authorization requirement, True: user must be pre-authorized to register, False: any user can register. -> - **domains:** _list, default None_ +> - **domains:** _list, optional, default None_ > - 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. -> - **fields:** _dict, default {'Form name':'Register user', 'Email':'Email', 'Username':'Username', 'Password':'Password', 'Repeat password':'Repeat password', 'Register':'Register'}_ +> - **fields:** _dict, optional, default {'Form name':'Register user', 'Email':'Email', 'Username':'Username', 'Password':'Password', 'Repeat password':'Repeat password', 'Register':'Register'}_ > - Customizes the text of headers, buttons and other fields. +> - **captcha:** _bool, default True_ +> - Specifies the captcha requirement for the register user widget, True: captcha required, False: captcha removed. > - **clear_on_submit:** _bool, default False_ > - Specifies the clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. +> - **key:** _str, default 'Register user'_ +> - Unique key provided to widget to avoid duplicate WidgetID errors. +> - **callback:** _callable, optional, default None_ +> - Optional callback function that will be invoked on form submission. > #### Returns: > - _str_ > - Email associated with the new user. @@ -228,11 +264,13 @@ except Exception as e: ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/register_user.JPG) -_Please remember to update the config file (as shown in step 9) after you use this widget._ +**_Please remember to update the config file (as shown in step 9) after you use this widget._** ### 6. Creating a forgot password widget -* You may use the **forgot_password** widget to allow a user to generate a new random password. This password will be automatically hashed and saved in the configuration file. The widget will return the username, email, and new random password which the developer should then transfer to the user securely. +* You may use the **forgot_password** widget to allow a user to generate a new random password. +* The new password will be automatically hashed and saved in the configuration file. +* The widget will return the username, email, and new random password which the developer should then transfer to the user securely. ```python try: @@ -250,10 +288,16 @@ except Exception as e: > #### Parameters > - **location:** _str, {'main', 'sidebar'}, default 'main'_ > - Specifies the location of the forgot password widget. -> - **fields:** _dict, default {'Form name':'Forgot password', 'Username':'Username', 'Submit':'Submit'}_ +> - **fields:** _dict, optional, default {'Form name':'Forgot password', 'Username':'Username', 'Submit':'Submit'}_ > - Customizes the text of headers, buttons and other fields. +> - **captcha:** _bool, default False_ +> - Specifies the captcha requirement for the forgot password widget, True: captcha required, False: captcha removed. > - **clear_on_submit:** _bool, default False_ > - Specifies the clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. +> - **key:** _str, default 'Forgot password'_ +> - Unique key provided to widget to avoid duplicate WidgetID errors. +> - **callback:** _callable, optional, default None_ +> - Optional callback function that will be invoked on form submission. > #### Returns: > - _str_ > - Username associated with the forgotten password. @@ -264,11 +308,12 @@ except Exception as e: ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/forgot_password.JPG) -_Please remember to update the config file (as shown in step 9) after you use this widget._ +**_Please remember to update the config file (as shown in step 9) after you use this widget._** ### 7. Creating a forgot username widget -* You may use the **forgot_username** widget to allow a user to retrieve their forgotten username. The widget will return the username and email which the developer should then transfer to the user securely. +* You may use the **forgot_username** widget to allow a user to retrieve their forgotten username. +* The widget will return the username and email which the developer should then transfer to the user securely. ```python try: @@ -286,10 +331,16 @@ except Exception as e: > #### Parameters > - **location:** _str, {'main', 'sidebar'}, default 'main'_ > - Specifies the location of the forgot username widget. -> - **fields:** _dict, default {'Form name':'Forgot username', 'Email':'Email', 'Submit':'Submit'}_ +> - **fields:** _dict, optional, default {'Form name':'Forgot username', 'Email':'Email', 'Submit':'Submit'}_ > - Customizes the text of headers, buttons and other fields. +> - **captcha:** _bool, default False_ +> - Specifies the captcha requirement for the forgot username widget, True: captcha required, False: captcha removed. > - **clear_on_submit:** _bool, default False_ > - Specifies the clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. +> - **key:** _str, default 'Forgot username'_ +> - Unique key provided to widget to avoid duplicate WidgetID errors. +> - **callback:** _callable, optional, default None_ +> - Optional callback function that will be invoked on form submission. > #### Returns: > - _str_ > - Forgotten username that should be transferred to the user securely. @@ -300,12 +351,13 @@ except Exception as e: ### 8. Creating an update user details widget -* You may use the **update_user_details** widget to allow a logged in user to update their name and/or email. The widget will automatically save the updated details in both the configuration file and re-authentication cookie. +* You may use the **update_user_details** widget to allow a logged in user to update their name and/or email. +* The widget will automatically save the updated details in both the configuration file and re-authentication cookie. ```python -if st.session_state["authentication_status"]: +if st.session_state['authentication_status']: try: - if authenticator.update_user_details(st.session_state["username"]): + if authenticator.update_user_details(st.session_state['username']): st.success('Entries updated successfully') except Exception as e: st.error(e) @@ -317,17 +369,21 @@ if st.session_state["authentication_status"]: > - Specifies the username of the user to update user details for. > - **location:** _str, {'main', 'sidebar'}, default 'main'_ > - Specifies the location of the update user details widget. -> - **fields:** _dict, default {'Form name':'Update user details', 'Field':'Field', 'Name':'Name', 'Email':'Email', 'New value':'New value', 'Update':'Update'}_ +> - **fields:** _dict, optional, default {'Form name':'Update user details', 'Field':'Field', 'Name':'Name', 'Email':'Email', 'New value':'New value', 'Update':'Update'}_ > - Customizes the text of headers, buttons and other fields. > - **clear_on_submit:** _bool, default False_ > - Specifies the clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. +> - **key:** _str, default 'Update user details'_ +> - Unique key provided to widget to avoid duplicate WidgetID errors. +> - **callback:** _callable, optional, default None_ +> - Optional callback function that will be invoked on form submission. > #### Returns: > - _bool_ > - Status of updating the user details. ![](https://github.com/mkhorasani/Streamlit-Authenticator/blob/main/graphics/update_user_details.JPG) -_Please remember to update the config file (as shown in step 9) after you use this widget._ +**_Please remember to update the config file (as shown in step 9) after you use this widget._** ### 9. Updating the configuration file diff --git a/config.yaml b/config.yaml index b5acdb4f..763fd4c2 100644 --- a/config.yaml +++ b/config.yaml @@ -13,7 +13,7 @@ credentials: jsmith: email: jsmith@gmail.com failed_login_attempts: 0 - logged_in: true + logged_in: false name: John Smith password: $2b$12$iWlVOac3uujRvTrXDi6wructXftKmo/GyQd6SMu5FmyX306kH.yFO rbriggs: @@ -28,12 +28,6 @@ credentials: logged_in: false name: Ross Couper password: $2b$12$Tir/PbHVmmnt5kgNxgOwMuxNIb2fv2pJ.q71TW8ekvbugCqkye4yu - wdewe: - email: wedw@ew.com - failed_login_attempts: 0 - logged_in: false - name: dwew - password: $2b$12$QJBPc7PxaTTBVJ.3cl4KlOPPqYCWVfaHqkk2IsoGDExXhihKZLDgy pre-authorized: emails: - melsby@gmail.com diff --git a/graphics/register_user.JPG b/graphics/register_user.JPG index 43420ce8be30216c732ad0270d8c14e977c4e336..82be3885f766f8aee0f06c6b4a62c6b54b9f941c 100644 GIT binary patch literal 36343 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 literal 30475 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%0DvR8bKTqlQP&W>OIxD<&$q7Tf7n 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 SNo3J=qp|;|*nk(R|Nj6UMqW|? diff --git a/setup.py b/setup.py index 0f8068b3..a2e5bc1f 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="streamlit-authenticator", - version="0.3.2", + version="0.3.3", author="Mohammad Khorasani", author_email="khorasani.mohammad@gmail.com", description="A secure authentication module to validate user credentials in a Streamlit application.", @@ -25,6 +25,7 @@ "PyJWT >=2.3.0", "bcrypt >= 3.1.7", "PyYAML >= 5.3.1", + "captcha >= 0.5.0", "streamlit >= 1.25.0", "extra-streamlit-components >= 0.1.70" ], diff --git a/streamlit_authenticator/__init__.py b/streamlit_authenticator/__init__.py index 535e85e6..37b4f9f4 100644 --- a/streamlit_authenticator/__init__.py +++ b/streamlit_authenticator/__init__.py @@ -12,13 +12,14 @@ import streamlit.components.v1 as components from yaml.loader import SafeLoader -from .authenticate import Authenticate -from .utilities.exceptions import (CredentialsError, - ForgotError, - LoginError, - RegisterError, - ResetError, - UpdateError) +from .views import Authenticate +from .utilities import (CredentialsError, + ForgotError, + Hasher, + LoginError, + RegisterError, + ResetError, + UpdateError) _RELEASE = True @@ -27,6 +28,9 @@ with open('../config.yaml', 'r', encoding='utf-8') as file: config = yaml.load(file, Loader=SafeLoader) + # Hashing all plain text passwords once + # Hasher.hash_passwords(config['credentials']) + # Creating the authenticator object authenticator = Authenticate( config['credentials'], @@ -42,26 +46,24 @@ except LoginError as e: st.error(e) - if st.session_state["authentication_status"]: + if st.session_state['authentication_status']: authenticator.logout() st.write(f'Welcome *{st.session_state["name"]}*') st.title('Some content') - elif st.session_state["authentication_status"] is False: + elif st.session_state['authentication_status'] is False: st.error('Username/password is incorrect') - elif st.session_state["authentication_status"] is None: + elif st.session_state['authentication_status'] is None: st.warning('Please enter your username and password') # Creating a password reset widget - if st.session_state["authentication_status"]: + if st.session_state['authentication_status']: try: - if authenticator.reset_password(st.session_state["username"]): + if authenticator.reset_password(st.session_state['username']): st.success('Password modified successfully') - except ResetError as e: - st.error(e) - except CredentialsError as e: + except (CredentialsError, ResetError) as e: st.error(e) - # # Creating a new user registration widget + # Creating a new user registration widget try: (email_of_registered_user, username_of_registered_user, @@ -71,7 +73,7 @@ except RegisterError as e: st.error(e) - # # Creating a forgot password widget + # Creating a forgot password widget try: (username_of_forgotten_password, email_of_forgotten_password, @@ -84,7 +86,7 @@ except ForgotError as e: st.error(e) - # # Creating a forgot username widget + # Creating a forgot username widget try: (username_of_forgotten_username, email_of_forgotten_username) = authenticator.forgot_username() @@ -96,10 +98,10 @@ except ForgotError as e: st.error(e) - # # Creating an update user details widget - if st.session_state["authentication_status"]: + # Creating an update user details widget + if st.session_state['authentication_status']: try: - if authenticator.update_user_details(st.session_state["username"]): + if authenticator.update_user_details(st.session_state['username']): st.success('Entries updated successfully') except UpdateError as e: st.error(e) diff --git a/streamlit_authenticator/authenticate/authentication/__init__.py b/streamlit_authenticator/authenticate/authentication/__init__.py deleted file mode 100644 index 6a761f6b..00000000 --- a/streamlit_authenticator/authenticate/authentication/__init__.py +++ /dev/null @@ -1,444 +0,0 @@ -""" -Script description: This module executes the logic for the login, logout, register user, -reset password, forgot password, forgot username, and modify user details widgets. - -Libraries imported: -- streamlit: Framework used to build pure Python web applications. -- typing: Module implementing standard typing notations for Python functions. -""" - -from typing import Optional -import streamlit as st - -from ...utilities.hasher import Hasher -from ...utilities.validator import Validator -from ...utilities.helpers import Helpers -from ...utilities.exceptions import (CredentialsError, - ForgotError, - LoginError, - RegisterError, - ResetError, - UpdateError) - -class AuthenticationHandler: - """ - This class will execute the logic for the login, logout, register user, reset password, - forgot password, forgot username, and modify user details widgets. - """ - def __init__(self, credentials: dict, pre_authorized: Optional[list]=None, - validator: Optional[Validator]=None): - """ - Create a new instance of "AuthenticationHandler". - - Parameters - ---------- - credentials: dict - Dictionary of usernames, names, passwords, emails, and other user data. - pre-authorized: list - List of emails of unregistered users who are authorized to register. - validator: Validator - Validator object that checks the validity of the username, name, and email fields. - """ - self.credentials = credentials - self.pre_authorized = pre_authorized - self.credentials['usernames'] = { - key.lower(): value - for key, value in credentials['usernames'].items() - } - self.validator = validator if validator is not None else Validator() - self.random_password = None - for username, _ in self.credentials['usernames'].items(): - if 'logged_in' not in self.credentials['usernames'][username]: - self.credentials['usernames'][username]['logged_in'] = False - if 'failed_login_attempts' not in self.credentials['usernames'][username]: - self.credentials['usernames'][username]['failed_login_attempts'] = 0 - if not Hasher._is_hash(self.credentials['usernames'][username]['password']): - self.credentials['usernames'][username]['password'] = \ - Hasher._hash(self.credentials['usernames'][username]['password']) - if 'name' not in st.session_state: - st.session_state['name'] = None - if 'authentication_status' not in st.session_state: - st.session_state['authentication_status'] = None - if 'username' not in st.session_state: - st.session_state['username'] = None - if 'logout' not in st.session_state: - st.session_state['logout'] = None - def check_credentials(self, username: str, password: str, - max_concurrent_users: Optional[int]=None, - max_login_attempts: Optional[int]=None) -> bool: - """ - Checks the validity of the entered credentials. - - Parameters - ---------- - username: str - The entered username. - password: str - The entered password. - max_concurrent_users: int - Maximum number of users allowed to login concurrently. - max_login_attempts: int - Maximum number of failed login attempts a user can make. - - Returns - ------- - bool - Validity of the entered credentials. - """ - if isinstance(max_concurrent_users, int): - if self._count_concurrent_users() > max_concurrent_users - 1: - raise LoginError('Maximum number of concurrent users exceeded') - if username in self.credentials['usernames']: - if isinstance(max_login_attempts, int): - if self.credentials['usernames'][username]['failed_login_attempts'] >= \ - max_login_attempts: - raise LoginError('Maximum number of login attempts exceeded') - try: - if Hasher.check_pw(password, self.credentials['usernames'][username]['password']): - return True - st.session_state['authentication_status'] = False - self._record_failed_login_attempts(username) - return False - except TypeError as e: - print(e) - except ValueError as e: - print(e) - else: - st.session_state['authentication_status'] = False - return False - return None - def _count_concurrent_users(self) -> int: - """ - Counts the number of users logged in concurrently. - - Returns - ------- - int - Number of users logged in concurrently. - """ - concurrent_users = 0 - for username, _ in self.credentials['usernames'].items(): - if self.credentials['usernames'][username]['logged_in']: - concurrent_users += 1 - return concurrent_users - def _credentials_contains_value(self, value: str) -> bool: - """ - Checks to see if a value is present in the credentials dictionary. - - Parameters - ---------- - value: str - Value being checked. - - Returns - ------- - bool - Presence/absence of the value, True: value present, False value absent. - """ - return any(value in d.values() for d in self.credentials['usernames'].values()) - def execute_login(self, username: Optional[str]=None, token: Optional[dict]=None): - """ - Executes login by setting authentication status to true and adding the user's - username and name to the session state. - - Parameters - ---------- - username: str - The username of the user being logged in. - token: dict - The re-authentication cookie to retrieve the username from. - """ - if username: - st.session_state['username'] = username - st.session_state['name'] = self.credentials['usernames'][username]['name'] - st.session_state['authentication_status'] = True - self._record_failed_login_attempts(username, reset=True) - self.credentials['usernames'][username]['logged_in'] = True - elif token: - st.session_state['username'] = token['username'] - st.session_state['name'] = self.credentials['usernames'][token['username']]['name'] - st.session_state['authentication_status'] = True - self.credentials['usernames'][token['username']]['logged_in'] = True - def execute_logout(self): - """ - Clears cookie and session state variables associated with the logged in user. - """ - self.credentials['usernames'][st.session_state['username']]['logged_in'] = False - st.session_state['logout'] = True - st.session_state['name'] = None - st.session_state['username'] = None - st.session_state['authentication_status'] = None - def forgot_password(self, username: str) -> tuple: - """ - Creates a new random password for the user. - - Parameters - ---------- - username: str - Username associated with the forgotten password. - - Returns - ------- - tuple - Username of the user; email of the user; new random password of the user. - """ - if not self.validator.validate_length(username, 1): - raise ForgotError('Username not provided') - if username in self.credentials['usernames']: - return (username, self.credentials['usernames'][username]['email'], - self._set_random_password(username)) - else: - return False, None, None - def forgot_username(self, email: str) -> tuple: - """ - Retrieves the forgotten username of a user. - - Parameters - ---------- - email: str - Email associated with the forgotten username. - - Returns - ------- - tuple - Username of the user; email of the user. - """ - if not self.validator.validate_length(email, 1): - raise ForgotError('Email not provided') - return self._get_username('email', email), email - def _get_username(self, key: str, value: str) -> str: - """ - Retrieves the username based on a provided entry. - - Parameters - ---------- - key: str - Name of the credential to query i.e. "email". - value: str - Value of the queried credential i.e. "jsmith@gmail.com". - - Returns - ------- - str - Username associated with the given key, value pair i.e. "jsmith". - """ - for username, values in self.credentials['usernames'].items(): - if values[key] == value: - return username - return False - def _record_failed_login_attempts(self, username: str, reset: bool=False): - """ - Records the number of failed login attempts for a given username. - - Parameters - ---------- - reset: bool - Reset failed login attempts option, True: number of failed login attempts - for the user will be reset to 0, - False: number of failed login attempts for the user will be incremented. - """ - if reset: - self.credentials['usernames'][username]['failed_login_attempts'] = 0 - else: - self.credentials['usernames'][username]['failed_login_attempts'] += 1 - def _register_credentials(self, username: str, name: str, password: str, email: str, - pre_authorization: bool, domains: list): - """ - Adds the new user's information to the credentials dictionary. - - Parameters - ---------- - username: str - Username of the new user. - name: str - Name of the new user. - password: str - Password of the new user. - email: str - Email of the new user. - pre-authorization: bool - Pre-authorization requirement, True: user must be pre-authorized to register, - False: any user can register. - domains: list - 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. - """ - if not self.validator.validate_email(email): - raise RegisterError('Email is not valid') - if self._credentials_contains_value(email): - raise RegisterError('Email already taken') - if domains: - if email.split('@')[1] not in ' '.join(domains): - raise RegisterError('Email not allowed to register') - if not self.validator.validate_username(username): - raise RegisterError('Username is not valid') - if username in self.credentials['usernames']: - raise RegisterError('Username already taken') - if not self.validator.validate_name(name): - raise RegisterError('Name is not valid') - self.credentials['usernames'][username] = \ - {'name': name, 'password': Hasher([password]).generate()[0], 'email': email, - 'logged_in': False} - if pre_authorization: - self.pre_authorized['emails'].remove(email) - def register_user(self, new_password: str, new_password_repeat: str, pre_authorization: bool, - new_username: str, new_name: str, new_email: str, - domains: Optional[list]=None) -> tuple: - """ - Validates a new user's username, password, and email. Subsequently adds the validated user - details to the credentials dictionary. - - Parameters - ---------- - new_password: str - Password of the new user. - new_password_repeat: str - Repeated password of the new user. - pre-authorization: bool - Pre-authorization requirement, True: user must be pre-authorized to register, - False: any user can register. - new_username: str - Username of the new user. - new_name: str - Name of the new user. - new_email: str - Email of the new user. - domains: list - 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. - - Returns - ------- - tuple - Email of the new user; username of the new user; name of the new user. - """ - if not self.validator.validate_length(new_password, 1) \ - or not self.validator.validate_length(new_password_repeat, 1): - raise RegisterError('Password/repeat password fields cannot be empty') - if new_password != new_password_repeat: - raise RegisterError('Passwords do not match') - if pre_authorization: - if new_email in self.pre_authorized['emails']: - self._register_credentials(new_username, new_name, new_password, new_email, - pre_authorization, domains) - return new_email, new_username, new_name - else: - raise RegisterError('User not pre-authorized to register') - else: - self._register_credentials(new_username, new_name, new_password, new_email, - pre_authorization, domains) - return new_email, new_username, new_name - - def reset_password(self, username: str, password: str, new_password: str, - new_password_repeat: str) -> bool: - """ - Validates the user's current password and subsequently saves their new password to the - credentials dictionary. - - Parameters - ---------- - username: str - Username of the user. - password: str - Current password of the user. - new_password: str - New password of the user. - new_password_repeat: str - Repeated new password of the user. - - Returns - ------- - bool - State of resetting the password, True: password reset successfully. - """ - if self.check_credentials(username, password): - if not self.validator.validate_length(new_password, 1): - raise ResetError('No new password provided') - if new_password != new_password_repeat: - raise ResetError('Passwords do not match') - if password != new_password: - self._update_password(username, new_password) - return True - else: - raise ResetError('New and current passwords are the same') - else: - raise CredentialsError('password') - def _set_random_password(self, username: str) -> str: - """ - Updates the credentials dictionary with the user's hashed random password. - - Parameters - ---------- - username: str - Username of the user to set the random password for. - - Returns - ------- - str - New plain text password that should be transferred to the user securely. - """ - self.random_password = Helpers.generate_random_pw() - self.credentials['usernames'][username]['password'] = \ - Hasher([self.random_password]).generate()[0] - return self.random_password - def _update_entry(self, username: str, key: str, value: str): - """ - Updates the credentials dictionary with the user's updated entry. - - Parameters - ---------- - username: str - Username of the user to update the entry for. - key: str - Updated entry key i.e. "email". - value: str - Updated entry value i.e. "jsmith@gmail.com". - """ - self.credentials['usernames'][username][key] = value - def _update_password(self, username: str, password: str): - """ - Updates the credentials dictionary with the user's hashed reset password. - - Parameters - ---------- - username: str - Username of the user to update the password for. - password: str - Updated plain text password. - """ - self.credentials['usernames'][username]['password'] = Hasher([password]).generate()[0] - def update_user_details(self, new_value: str, username: str, field: str) -> bool: - """ - Validates the user's updated name or email and subsequently modifies it in the - credentials dictionary. - - Parameters - ---------- - new_value: str - New value for the name or email. - username: str - Username of the user. - field: str - Field to update i.e. name or email. - - Returns - ------- - bool - State of updating the user's detail, True: details updated successfully. - """ - if field == 'name': - if not self.validator.validate_name(new_value): - raise UpdateError('Name is not valid') - if field == 'email': - if not self.validator.validate_email(new_value): - raise UpdateError('Email is not valid') - if self._credentials_contains_value(new_value): - raise UpdateError('Email already taken') - if new_value != self.credentials['usernames'][username][field]: - self._update_entry(username, field, new_value) - if field == 'name': - st.session_state['name'] = new_value - return True - else: - raise UpdateError('New and current values are the same') - \ No newline at end of file diff --git a/streamlit_authenticator/controllers/__init__.py b/streamlit_authenticator/controllers/__init__.py new file mode 100644 index 00000000..47f1b90b --- /dev/null +++ b/streamlit_authenticator/controllers/__init__.py @@ -0,0 +1,2 @@ +from .authentication_controller import AuthenticationController +from .cookie_controller import CookieController diff --git a/streamlit_authenticator/controllers/authentication_controller.py b/streamlit_authenticator/controllers/authentication_controller.py new file mode 100644 index 00000000..1447d98a --- /dev/null +++ b/streamlit_authenticator/controllers/authentication_controller.py @@ -0,0 +1,329 @@ +""" +Script description: This module controls the requests for the login, logout, register user, +reset password, forgot password, forgot username, and modify user details widgets. + +Libraries imported: +- typing: Module implementing standard typing notations for Python functions. +- streamlit: Framework used to build pure Python web applications. +""" + +from typing import Callable, Dict, List, Optional +import streamlit as st + +from ..models import AuthenticationModel +from ..utilities import (ForgotError, + Helpers, + LoginError, + RegisterError, + ResetError, + UpdateError, + Validator) + +class AuthenticationController: + """ + This class controls the requests for the login, logout, register user, reset password, + forgot password, forgot username, and modify user details widgets. + """ + def __init__(self, credentials: dict, pre_authorized: Optional[List[str]]=None, + validator: Optional[Validator]=None, auto_hash: bool=True): + """ + Create a new instance of "AuthenticationController". + + Parameters + ---------- + credentials: dict + Dictionary of usernames, names, passwords, emails, and other user data. + pre-authorized: list, optional + List of emails of unregistered users who are authorized to register. + validator: Validator, optional + Validator object that checks the validity of the username, name, and email fields. + auto_hash: bool + Automatic hashing requirement for the passwords, + True: plain text passwords will be automatically hashed, + False: plain text passwords will not be automatically hashed. + """ + self.authentication_model = AuthenticationModel(credentials, + pre_authorized, + validator, + auto_hash) + self.validator = Validator() + def _check_captcha(self, captcha_name: str, exception: Exception, entered_captcha: str): + """ + Checks the validity of the entered captcha. + + Parameters + ---------- + captcha_name: str + Name of the generated captcha stored in the session state. + exception: Exception + Type of exception to be raised. + entered_captcha: str, optional + User entered captcha to validate against the generated captcha. + """ + if Helpers.check_captcha(captcha_name, entered_captcha): + del st.session_state[captcha_name] + else: + raise exception('Captcha entered incorrectly') + def forgot_password(self, username: str, callback: Optional[Callable]=None, + captcha: bool=False, entered_captcha: Optional[str]=None) -> tuple: + """ + Controls the request to create a new random password for the user. + + Parameters + ---------- + username: str + Username associated with the forgotten password. + callback: callable, optional + Optional callback function that will be invoked on form submission. + captcha: bool + Captcha requirement for the login widget, + True: captcha required, + False: captcha removed. + entered_captcha: str, optional + User entered captcha to validate against the generated captcha. + + Returns + ------- + str + Username of the user. + str + Email of the user. + str + New random password of the user. + """ + username = username.lower().strip() + if captcha: + if not entered_captcha: + raise ForgotError('Captcha not entered') + entered_captcha = entered_captcha.strip() + self._check_captcha('forgot_password_captcha', ForgotError, entered_captcha) + if not self.validator.validate_length(username, 1): + raise ForgotError('Username not provided') + return self.authentication_model.forgot_password(username, callback) + def forgot_username(self, email: str, callback: Optional[Callable]=None, + captcha: bool=False, entered_captcha: Optional[str]=None) -> tuple: + """ + Controls the request to get the forgotten username of the user. + + Parameters + ---------- + email: str + Email associated with the forgotten username. + callback: callable, optional + Optional callback function that will be invoked on form submission. + captcha: bool + Captcha requirement for the login widget, + True: captcha required, + False: captcha removed. + entered_captcha: str, optional + User entered captcha to validate against the generated captcha. + + Returns + ------- + str + Username of the user. + str + Email of the user. + """ + email = email.strip() + if captcha: + if not entered_captcha: + raise ForgotError('Captcha not entered') + entered_captcha = entered_captcha.strip() + self._check_captcha('forgot_username_captcha', ForgotError, entered_captcha) + if not self.validator.validate_length(email, 1): + raise ForgotError('Email not provided') + return self.authentication_model.forgot_username(email, callback) + def login(self, username: Optional[str]=None, password: Optional[str]=None, + max_concurrent_users: Optional[int]=None, max_login_attempts: Optional[int]=None, + token: Optional[Dict[str, str]]=None, callback: Optional[Callable]=None, + captcha: bool=False, entered_captcha: Optional[str]=None): + """ + Controls the request to login the user. + + Parameters + ---------- + username: str, optional + The username of the user being logged in. + password: str, optional + The entered password. + max_concurrent_users: int, optional + Maximum number of users allowed to login concurrently. + max_login_attempts: int, optional + Maximum number of failed login attempts a user can make. + token: dict, optional + The re-authentication cookie to get the username from. + callback: callable, optional + Optional callback function that will be invoked on form submission. + captcha: bool + Captcha requirement for the login widget, + True: captcha required, + False: captcha removed. + entered_captcha: str, optional + User entered captcha to validate against the generated captcha. + + Returns + ------- + bool + Status of authentication, + None: no credentials entered, + True: correct credentials, + False: incorrect credentials. + """ + if username and password: + username = username.lower().strip() + password = password.strip() + if captcha: + if not entered_captcha: + raise LoginError('Captcha not entered') + entered_captcha = entered_captcha.strip() + self._check_captcha('login_captcha', LoginError, entered_captcha) + return self.authentication_model.login(username, password, max_concurrent_users, + max_login_attempts, token, callback) + def logout(self): + """ + Controls the request to logout the user. + + """ + self.authentication_model.logout() + def register_user(self, new_name: str, new_email: str, new_username: str, + new_password: str, new_password_repeat: str, pre_authorization: bool, + domains: Optional[List[str]]=None, callback: Optional[Callable]=None, + captcha: bool=False, entered_captcha: Optional[str]=None) -> tuple: + """ + Controls the request to register the new user's name, username, password, and email. + + Parameters + ---------- + new_name: str + Name of the new user. + new_email: str + Email of the new user. + new_username: str + Username of the new user. + new_password: str + Password of the new user. + new_password_repeat: str + Repeated password of the new user. + pre-authorization: bool + Pre-authorization requirement, + True: user must be pre-authorized to register, + False: any user can register. + domains: list, optional + 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. + callback: callable, optional + Optional callback function that will be invoked on form submission. + captcha: bool + Captcha requirement for the login widget, + True: captcha required, + False: captcha removed. + entered_captcha: str, optional + User entered captcha to validate against the generated captcha. + + Returns + ------- + str + Email of the new user. + str + Username of the new user. + str + Name of the new user. + """ + new_name = new_name.strip() + new_email = new_email.strip() + new_username = new_username.lower().strip() + new_password = new_password.strip() + new_password_repeat = new_password_repeat.strip() + if not self.validator.validate_name(new_name): + raise RegisterError('Name is not valid') + if not self.validator.validate_email(new_email): + raise RegisterError('Email is not valid') + if domains: + if new_email.split('@')[1] not in ' '.join(domains): + raise RegisterError('Email not allowed to register') + if not self.validator.validate_username(new_username): + raise RegisterError('Username is not valid') + if not self.validator.validate_length(new_password, 1) \ + or not self.validator.validate_length(new_password_repeat, 1): + raise RegisterError('Password/repeat password fields cannot be empty') + if new_password != new_password_repeat: + raise RegisterError('Passwords do not match') + if not self.validator.validate_password(new_password): + raise RegisterError('Password does not meet criteria') + if pre_authorization: + if not self.authentication_model.pre_authorized: + raise RegisterError('Pre-authorization argument must not be None') + if captcha: + if not entered_captcha: + raise RegisterError('Captcha not entered') + entered_captcha = entered_captcha.strip() + self._check_captcha('register_user_captcha', RegisterError, entered_captcha) + return self.authentication_model.register_user(new_name, new_email, new_username, + new_password, pre_authorization, + callback) + def reset_password(self, username: str, password: str, new_password: str, + new_password_repeat: str, callback: Optional[Callable]=None) -> bool: + """ + Controls the request to reset the user's password. + + Parameters + ---------- + username: str + Username of the user. + password: str + Current password of the user. + new_password: str + New password of the user. + new_password_repeat: str + Repeated new password of the user. + callback: callable, optional + Optional callback function that will be invoked on form submission. + + Returns + ------- + bool + State of resetting the password, + True: password reset successfully. + """ + if not self.validator.validate_length(new_password, 1): + raise ResetError('No new password provided') + if new_password != new_password_repeat: + raise ResetError('Passwords do not match') + if password == new_password: + raise ResetError('New and current passwords are the same') + if not self.validator.validate_password(new_password): + raise ResetError('Password does not meet criteria') + return self.authentication_model.reset_password(username, password, new_password, + callback) + def update_user_details(self, new_value: str, username: str, field: str, + callback: Optional[Callable]=None) -> bool: + """ + Controls the request to update the user's name or email. + + Parameters + ---------- + new_value: str + New value for the name or email. + username: str + Username of the user. + field: str + Field to update i.e. name or email. + callback: callable, optional + Optional callback function that will be invoked on form submission. + + Returns + ------- + bool + State of updating the user's detail, + True: details updated successfully. + """ + if field == 'name': + if not self.validator.validate_name(new_value): + raise UpdateError('Name is not valid') + if field == 'email': + if not self.validator.validate_email(new_value): + raise UpdateError('Email is not valid') + return self.authentication_model.update_user_details(new_value, username, field, + callback) diff --git a/streamlit_authenticator/controllers/cookie_controller.py b/streamlit_authenticator/controllers/cookie_controller.py new file mode 100644 index 00000000..7c691100 --- /dev/null +++ b/streamlit_authenticator/controllers/cookie_controller.py @@ -0,0 +1,49 @@ +""" +Script description: This module controls requests made to the cookie model for password-less +re-authentication. +""" + +from ..models import CookieModel + +class CookieController: + """ + This class controls all requests made to the cookie model for password-less re-authentication, + including deleting, getting, and setting the cookie. + """ + def __init__(self, cookie_name: str, cookie_key: str, cookie_expiry_days: float): + """ + Create a new instance of "CookieController". + + Parameters + ---------- + cookie_name: str + Name of the cookie stored on the client's browser for password-less re-authentication. + cookie_key: str + Key to be used to hash the signature of the re-authentication cookie. + cookie_expiry_days: float + Number of days before the re-authentication cookie automatically expires on the client's + browser. + """ + self.cookie_model = CookieModel(cookie_name, + cookie_key, + cookie_expiry_days) + def delete_cookie(self): + """ + Deletes the re-authentication cookie. + """ + self.cookie_model.delete_cookie() + def get_cookie(self): + """ + Gets the re-authentication cookie. + + Returns + ------- + str + Re-authentication cookie. + """ + return self.cookie_model.get_cookie() + def set_cookie(self): + """ + Sets the re-authentication cookie. + """ + self.cookie_model.set_cookie() diff --git a/streamlit_authenticator/models/__init__.py b/streamlit_authenticator/models/__init__.py new file mode 100644 index 00000000..7a0851c0 --- /dev/null +++ b/streamlit_authenticator/models/__init__.py @@ -0,0 +1,2 @@ +from .cookie_model import CookieModel +from .authentication_model import AuthenticationModel diff --git a/streamlit_authenticator/models/authentication_model.py b/streamlit_authenticator/models/authentication_model.py new file mode 100644 index 00000000..81622e8f --- /dev/null +++ b/streamlit_authenticator/models/authentication_model.py @@ -0,0 +1,479 @@ +""" +Script description: This module executes the logic for the login, logout, register user, +reset password, forgot password, forgot username, and modify user details widgets. + +Libraries imported: +- typing: Module implementing standard typing notations for Python functions. +- streamlit: Framework used to build pure Python web applications. +""" + +from typing import Callable, Dict, List, Optional +import streamlit as st + +from .. import params +from ..utilities import (Hasher, + Helpers, + CredentialsError, + LoginError, + RegisterError, + UpdateError, + Validator) + +class AuthenticationModel: + """ + This class executes the logic for the login, logout, register user, reset password, + forgot password, forgot username, and modify user details widgets. + """ + def __init__(self, credentials: dict, pre_authorized: Optional[List[str]]=None, + validator: Optional[Validator]=None, auto_hash: bool=True): + """ + Create a new instance of "AuthenticationService". + + Parameters + ---------- + credentials: dict + Dictionary of usernames, names, passwords, emails, and other user data. + pre-authorized: list, optional + List of emails of unregistered users who are authorized to register. + validator: Validator, optional + Validator object that checks the validity of the username, name, and email fields. + auto_hash: bool + Automatic hashing requirement for the passwords, + True: plain text passwords will be automatically hashed, + False: plain text passwords will not be automatically hashed. + """ + self.credentials = credentials + if self.credentials['usernames']: + if 'AuthenticationService.__init__' not in st.session_state: + st.session_state['AuthenticationService.__init__'] = None + if not st.session_state['AuthenticationService.__init__']: + self.credentials['usernames'] = { + key.lower(): value + for key, value in self.credentials['usernames'].items() + } + if auto_hash: + if len(self.credentials['usernames']) > params.AUTO_HASH_MAX_USERS: + print(f"""Auto hashing in progress. To avoid runtime delays, please manually + pre-hash all plain text passwords in the credentials using the + Hasher.hash_passwords function, and set auto_hash=False for the + Authenticate class. For more information please refer to + {params.AUTO_HASH_MAX_USERS_LINK}.""") + for username, _ in self.credentials['usernames'].items(): + if not Hasher._is_hash(self.credentials['usernames'][username]['password']): + self.credentials['usernames'][username]['password'] = \ + Hasher._hash(self.credentials['usernames'][username]['password']) + st.session_state['AuthenticationService.__init__'] = True + else: + self.credentials['usernames'] = {} + self.pre_authorized = pre_authorized + self.validator = validator if validator is not None else Validator() + if 'name' not in st.session_state: + st.session_state['name'] = None + if 'authentication_status' not in st.session_state: + st.session_state['authentication_status'] = None + if 'username' not in st.session_state: + st.session_state['username'] = None + if 'logout' not in st.session_state: + st.session_state['logout'] = None + def check_credentials(self, username: str, password: str, + max_concurrent_users: Optional[int]=None, + max_login_attempts: Optional[int]=None) -> bool: + """ + Checks the validity of the entered credentials. + + Parameters + ---------- + username: str + The entered username. + password: str + The entered password. + max_concurrent_users: int, optional + Maximum number of users allowed to login concurrently. + max_login_attempts: int, optional + Maximum number of failed login attempts a user can make. + + Returns + ------- + bool + Validity of entered credentials, + None: no credentials entered, + True: correct credentials, + False: incorrect credentials. + """ + if isinstance(max_concurrent_users, int) and self._count_concurrent_users() > \ + max_concurrent_users - 1: + raise LoginError('Maximum number of concurrent users exceeded') + if username not in self.credentials['usernames']: + return False + if isinstance(max_login_attempts, int) and \ + 'failed_login_attempts' in self.credentials['usernames'][username] and \ + self.credentials['usernames'][username]['failed_login_attempts'] >= max_login_attempts: + raise LoginError('Maximum number of login attempts exceeded') + try: + if Hasher.check_pw(password, self.credentials['usernames'][username]['password']): + return True + self._record_failed_login_attempts(username) + return False + except (TypeError, ValueError) as e: + print(e) + return None + def _count_concurrent_users(self) -> int: + """ + Counts the number of users logged in concurrently. + + Returns + ------- + int + Number of users logged in concurrently. + """ + concurrent_users = 0 + for username, _ in self.credentials['usernames'].items(): + if 'logged_in' in self.credentials['usernames'][username] and \ + self.credentials['usernames'][username]['logged_in']: + concurrent_users += 1 + return concurrent_users + def _credentials_contains_value(self, value: str) -> bool: + """ + Checks to see if a value is present in the credentials dictionary. + + Parameters + ---------- + value: str + Value being checked. + + Returns + ------- + bool + Presence/absence of the value, + True: value present, + False value absent. + """ + return any(value in d.values() for d in self.credentials['usernames'].values()) + def forgot_password(self, username: str, callback: Optional[Callable]=None) -> tuple: + """ + Creates a new random password for the user. + + Parameters + ---------- + username: str + Username associated with the forgotten password. + callback: callable, optional + Optional callback function that will be invoked on form submission. + + Returns + ------- + str + Username of the user. + str + Email of the user. + str + New random password of the user. + """ + if username in self.credentials['usernames']: + if callback: + callback({'username': username}) + return (username, self._get_credentials()[username]['email'], + self._set_random_password(username)) + return False, None, None + def forgot_username(self, email: str, callback: Optional[Callable]=None) -> tuple: + """ + Gets the forgotten username of a user. + + Parameters + ---------- + email: str + Email associated with the forgotten username. + callback: callable, optional + Optional callback function that will be invoked on form submission. + + Returns + ------- + str + Username of the user. + str + Email of the user. + """ + if callback: + callback({'email': email}) + return self._get_username('email', email), email + def _get_credentials(self) -> dict: + """ + Gets the user credentials dictionary. + + Returns + ------- + dict + User credentials dictionary. + """ + return self.credentials['usernames'] + def _get_username(self, key: str, value: str) -> str: + """ + Gets the username based on a provided entry. + + Parameters + ---------- + key: str + Name of the credential to query i.e. "email". + value: str + Value of the queried credential i.e. "jsmith@gmail.com". + + Returns + ------- + str + Username associated with the given key, value pair i.e. "jsmith". + """ + for username, values in self.credentials['usernames'].items(): + if values[key] == value: + return username + return False + def login(self, username: str, password: str, max_concurrent_users: Optional[int]=None, + max_login_attempts: Optional[int]=None, token: Optional[Dict[str, str]]=None, + callback: Optional[Callable]=None) -> bool: + """ + Executes login by setting authentication status to true and adding the user's + username and name to the session state. + + Parameters + ---------- + username: str + The entered username. + password: str + The entered password. + max_concurrent_users: int, optional + Maximum number of users allowed to login concurrently. + max_login_attempts: int, optional + Maximum number of failed login attempts a user can make. + token: dict, optional + The re-authentication cookie to get the username from. + callback: callable, optional + Optional callback function that will be invoked on form submission. + + Returns + ------- + bool + Status of authentication, + None: no credentials entered, + True: correct credentials, + False: incorrect credentials. + """ + if username: + if self.check_credentials(username, password, max_concurrent_users, max_login_attempts): + st.session_state['username'] = username + st.session_state['name'] = self.credentials['usernames'][username]['name'] + st.session_state['authentication_status'] = True + self._record_failed_login_attempts(username, reset=True) + self.credentials['usernames'][username]['logged_in'] = True + if callback: + callback({'username': username}) + return True + st.session_state['authentication_status'] = False + return False + if token: + if not token['username'] in self.credentials['usernames']: + raise LoginError('User not authorized') + st.session_state['username'] = token['username'] + st.session_state['name'] = self.credentials['usernames'][token['username']]['name'] + st.session_state['authentication_status'] = True + self.credentials['usernames'][token['username']]['logged_in'] = True + return None + def logout(self): + """ + Clears the cookie and session state variables associated with the logged in user. + """ + self.credentials['usernames'][st.session_state['username']]['logged_in'] = False + st.session_state['logout'] = True + st.session_state['name'] = None + st.session_state['username'] = None + st.session_state['authentication_status'] = None + def _record_failed_login_attempts(self, username: str, reset: bool=False): + """ + Records the number of failed login attempts for a given username. + + Parameters + ---------- + username: str + The entered username. + reset: bool + Reset failed login attempts option, + True: number of failed login attempts for the user will be reset to 0, + False: number of failed login attempts for the user will be incremented. + """ + if 'failed_login_attempts' not in self.credentials['usernames'][username]: + self.credentials['usernames'][username]['failed_login_attempts'] = 0 + if reset: + self.credentials['usernames'][username]['failed_login_attempts'] = 0 + else: + self.credentials['usernames'][username]['failed_login_attempts'] += 1 + def _register_credentials(self, username: str, name: str, password: str, email: str): + """ + Adds the new user's information to the credentials dictionary. + + Parameters + ---------- + username: str + Username of the new user. + name: str + Name of the new user. + password: str + Password of the new user. + email: str + Email of the new user. + """ + self.credentials['usernames'][username] = \ + {'name': name, 'password': Hasher([password]).generate()[0], 'email': email, + 'logged_in': False} + def register_user(self, new_name: str, new_email: str, new_username: str, + new_password: str, pre_authorization: bool, + callback: Optional[Callable]=None) -> tuple: + """ + Registers a new user's name, username, password, and email. + + Parameters + ---------- + new_name: str + Name of the new user. + new_email: str + Email of the new user. + new_username: str + Username of the new user. + new_password: str + Password of the new user. + pre-authorization: bool + Pre-authorization requirement, + True: user must be pre-authorized to register, + False: any user can register. + callback: callable, optional + Optional callback function that will be invoked on form submission. + + Returns + ------- + str + Email of the new user. + str + Username of the new user. + str + Name of the new user. + """ + if self._credentials_contains_value(new_email): + raise RegisterError('Email already taken') + if new_username in self.credentials['usernames']: + raise RegisterError('Username already taken') + if callback: + callback({'new_name': new_name, 'new_email': new_email, + 'new_username': new_username}) + if pre_authorization: + if new_email in self.pre_authorized['emails']: + self._register_credentials(new_username, new_name, new_password, new_email) + self.pre_authorized['emails'].remove(new_email) + return new_email, new_username, new_name + raise RegisterError('User not pre-authorized to register') + self._register_credentials(new_username, new_name, new_password, new_email) + return new_email, new_username, new_name + def reset_password(self, username: str, password: str, new_password: str, + callback: Optional[Callable]=None) -> bool: + """ + Validates the user's current password and subsequently saves their new password to the + credentials dictionary. + + Parameters + ---------- + username: str + Username of the user. + password: str + Current password of the user. + new_password: str + New password of the user. + callback: callable, optional + Optional callback function that will be invoked on form submission. + + Returns + ------- + bool + State of resetting the password, + True: password reset successfully. + """ + if not self.check_credentials(username, password): + raise CredentialsError('password') + self._update_password(username, new_password) + self._record_failed_login_attempts(username, reset=True) + if callback: + callback({}) + return True + def _set_random_password(self, username: str) -> str: + """ + Updates the credentials dictionary with the user's hashed random password. + + Parameters + ---------- + username: str + Username of the user to set the random password for. + + Returns + ------- + str + New plain text password that should be transferred to the user securely. + """ + random_password = Helpers.generate_random_pw() + self.credentials['usernames'][username]['password'] = \ + Hasher([random_password]).generate()[0] + return random_password + def _update_entry(self, username: str, key: str, value: str): + """ + Updates the credentials dictionary with the user's updated entry. + + Parameters + ---------- + username: str + Username of the user to update the entry for. + key: str + Updated entry key i.e. "email". + value: str + Updated entry value i.e. "jsmith@gmail.com". + """ + self.credentials['usernames'][username][key] = value + def _update_password(self, username: str, password: str): + """ + Updates the credentials dictionary with the user's hashed reset password. + + Parameters + ---------- + username: str + Username of the user to update the password for. + password: str + Updated plain text password. + """ + self.credentials['usernames'][username]['password'] = Hasher([password]).generate()[0] + def update_user_details(self, new_value: str, username: str, field: str, + callback: Optional[Callable]=None) -> bool: + """ + Validates the user's updated name or email and subsequently modifies it in the + credentials dictionary. + + Parameters + ---------- + new_value: str + New value for the name or email. + username: str + Username of the user. + field: str + Field to update i.e. name or email. + callback: callable, optional + Optional callback function that will be invoked on form submission. + + Returns + ------- + bool + State of updating the user's detail, + True: details updated successfully. + """ + if field == 'email': + if self._credentials_contains_value(new_value): + raise UpdateError('Email already taken') + if new_value != self.credentials['usernames'][username][field]: + self._update_entry(username, field, new_value) + if field == 'name': + st.session_state['name'] = new_value + if callback: + callback({'field': field, 'new_value': new_value}) + return True + raise UpdateError('New and current values are the same') diff --git a/streamlit_authenticator/authenticate/cookie/__init__.py b/streamlit_authenticator/models/cookie_model.py similarity index 74% rename from streamlit_authenticator/authenticate/cookie/__init__.py rename to streamlit_authenticator/models/cookie_model.py index 8ccef8dd..86248581 100644 --- a/streamlit_authenticator/authenticate/cookie/__init__.py +++ b/streamlit_authenticator/models/cookie_model.py @@ -1,5 +1,6 @@ """ -Script description: This module implements cookies for password-less re-authentication. +Script description: This module executes the logic for the cookies for password-less +re-authentication. Libraries imported: - datetime: Module implementing DateTime data types. @@ -14,14 +15,14 @@ import streamlit as st import extra_streamlit_components as stx -class CookieHandler: +class CookieModel: """ - This class will execute all actions related to the re-authentication cookie, - including retrieving, deleting, and setting the cookie. + This class executes the logic for the cookies for password-less re-authentication, + including deleting, getting, and setting the cookie. """ - def __init__(self, cookie_name: str, cookie_key: str, cookie_expiry_days: float=30.0): + def __init__(self, cookie_name: str, cookie_key: str, cookie_expiry_days: float): """ - Create a new instance of "CookieHandler". + Create a new instance of "CookieService". Parameters ---------- @@ -39,14 +40,22 @@ def __init__(self, cookie_name: str, cookie_key: str, cookie_expiry_days: float= self.cookie_manager = stx.CookieManager() self.token = None self.exp_date = None + def delete_cookie(self): + """ + Deletes the re-authentication cookie. + """ + try: + self.cookie_manager.delete(self.cookie_name) + except KeyError as e: + print(e) def get_cookie(self) -> str: """ - Retrieves, checks, and then returns the re-authentication cookie. + Gets, checks, and then returns the re-authentication cookie. Returns ------- str - re-authentication cookie. + Re-authentication cookie. """ if st.session_state['logout']: return False @@ -54,24 +63,19 @@ def get_cookie(self) -> str: if self.token is not None: self.token = self._token_decode() if (self.token is not False and 'username' in self.token and - self.token['exp_date'] > datetime.utcnow().timestamp()): + self.token['exp_date'] > datetime.now().timestamp()): return self.token - def delete_cookie(self): - """ - Deletes the re-authentication cookie. - """ - try: - self.cookie_manager.delete(self.cookie_name) - except KeyError as e: - print(e) + return None def set_cookie(self): """ Sets the re-authentication cookie. """ - self.exp_date = self._set_exp_date() - token = self._token_encode() - self.cookie_manager.set(self.cookie_name, token, - expires_at=datetime.now() + timedelta(days=self.cookie_expiry_days)) + if self.cookie_expiry_days != 0: + self.exp_date = self._set_exp_date() + token = self._token_encode() + self.cookie_manager.set(self.cookie_name, token, + expires_at=datetime.now() + \ + timedelta(days=self.cookie_expiry_days)) def _set_exp_date(self) -> str: """ Sets the re-authentication cookie's expiry date. @@ -81,7 +85,7 @@ def _set_exp_date(self) -> str: str re-authentication cookie's expiry timestamp in Unix Epoch. """ - return (datetime.utcnow() + timedelta(days=self.cookie_expiry_days)).timestamp() + return (datetime.now() + timedelta(days=self.cookie_expiry_days)).timestamp() def _token_decode(self) -> str: """ Decodes the contents of the re-authentication cookie. @@ -93,10 +97,7 @@ def _token_decode(self) -> str: """ try: return jwt.decode(self.token, self.cookie_key, algorithms=['HS256']) - except InvalidSignatureError as e: - print(e) - return False - except DecodeError as e: + except (DecodeError, InvalidSignatureError) as e: print(e) return False def _token_encode(self) -> str: diff --git a/streamlit_authenticator/params.py b/streamlit_authenticator/params.py new file mode 100644 index 00000000..bd01d79b --- /dev/null +++ b/streamlit_authenticator/params.py @@ -0,0 +1,7 @@ +""" +Configuration parameters and links for the Streamlit-Authenticator package. +""" + +LOGIN_SLEEP_TIME: float = 1.0 +AUTO_HASH_MAX_USERS: int = 30 +AUTO_HASH_MAX_USERS_LINK: str = 'https://github.com/mkhorasani/Streamlit-Authenticator?tab=readme-ov-file#1-creating-a-configuration-file' diff --git a/streamlit_authenticator/utilities/__init__.py b/streamlit_authenticator/utilities/__init__.py index e69de29b..e05d0662 100644 --- a/streamlit_authenticator/utilities/__init__.py +++ b/streamlit_authenticator/utilities/__init__.py @@ -0,0 +1,12 @@ +from .exceptions import (AuthenticateError, + CredentialsError, + DeprecationError, + ForgotError, + LoginError, + LogoutError, + RegisterError, + ResetError, + UpdateError) +from .hasher import Hasher +from .helpers import Helpers +from .validator import Validator diff --git a/streamlit_authenticator/utilities/exceptions.py b/streamlit_authenticator/utilities/exceptions.py index 87743503..2abf46b2 100644 --- a/streamlit_authenticator/utilities/exceptions.py +++ b/streamlit_authenticator/utilities/exceptions.py @@ -3,6 +3,19 @@ Register, Reset, and Update errors. """ +class AuthenticateError(Exception): + """ + Exceptions raised for the Authenticate class. + + Attributes + ---------- + message: str + The custom error message to display. + """ + def __init__(self, message: str): + self.message = message + super().__init__(self.message) + class CredentialsError(Exception): """ Exception raised for incorrect credentials. @@ -54,6 +67,19 @@ def __init__(self, message: str): self.message = message super().__init__(self.message) +class LogoutError(Exception): + """ + Exceptions raised for the Logout button. + + Attributes + ---------- + message: str + The custom error message to display. + """ + def __init__(self, message: str): + self.message = message + super().__init__(self.message) + class RegisterError(Exception): """ Exceptions raised for the register user widget. diff --git a/streamlit_authenticator/utilities/hasher.py b/streamlit_authenticator/utilities/hasher.py index a02df74b..e1efab83 100644 --- a/streamlit_authenticator/utilities/hasher.py +++ b/streamlit_authenticator/utilities/hasher.py @@ -24,10 +24,16 @@ def __init__(self, passwords: list): """ self.passwords = passwords @classmethod - def check_pw(cls, password, hashed_password) -> bool: + def check_pw(cls, password: str, hashed_password: str) -> bool: """ Checks the validity of the entered password. + Parameters + ---------- + password: str + The plain text password. + hashed_password: str + The hashed password. Returns ------- bool @@ -45,6 +51,28 @@ def generate(self) -> list: """ return [self._hash(password) for password in self.passwords] @classmethod + def hash_passwords(cls, credentials: dict) -> dict: + """ + Hashes all plain text passwords in the credentials dict. + + Parameters + ---------- + credentials: dict + The credentials dict with plain text passwords. + Returns + ------- + dict + The credentials dict with hashed passwords. + """ + usernames = credentials['usernames'] + + for _, user in usernames.items(): + password = user['password'] + if not cls._is_hash(password): + hashed_password = cls._hash(password) + user['password'] = hashed_password + return credentials + @classmethod def _hash(cls, password: str) -> str: """ Hashes the plain text password. @@ -52,7 +80,7 @@ def _hash(cls, password: str) -> str: Parameters ---------- password: str - The plain text password to be hashed. + The plain text password. Returns ------- str @@ -67,7 +95,9 @@ def _is_hash(cls, hash_string: str) -> bool: Returns ------- bool - The validity of the hash string. + The state of whether the string is a hash, + True: the string is a hash, + False: the string is not a hash. """ bcrypt_regex = re.compile(r'^\$2[aby]\$\d+\$.{53}$') return bool(bcrypt_regex.match(hash_string)) diff --git a/streamlit_authenticator/utilities/helpers.py b/streamlit_authenticator/utilities/helpers.py index e85f077c..dd2152f5 100644 --- a/streamlit_authenticator/utilities/helpers.py +++ b/streamlit_authenticator/utilities/helpers.py @@ -1,15 +1,17 @@ """ Script description: This module executes the logic for miscellaneous functions for this -library. +library. Libraries imported: - string: Module providing support for ASCII character encoding. - random: Module generating random characters. +- streamlit: Framework used to build pure Python web applications. - captcha: Module generating captcha images. """ import string import random +import streamlit as st from captcha.image import ImageCaptcha class Helpers: @@ -19,34 +21,61 @@ class Helpers: def __init__(self): pass @classmethod - def generate_random_pw(cls, length: int=16) -> str: + def check_captcha(cls, captcha_name: str, entered_captcha: str): """ - Generates a random password. + Checks the validity of the entered captcha. Parameters ---------- - length: int - The length of the returned password. + captcha_name: str + Name of the generated captcha stored in the session state. + entered_captcha: str, optional + User entered captcha to validate against the generated captcha. + Returns ------- - str - The randomly generated password. + bool + Validity of entered captcha, + True: captcha is valid, + False: captcha is invalid. """ - letters = string.ascii_letters + string.digits - return ''.join(random.choice(letters) for i in range(length)).replace(' ','') + if entered_captcha == st.session_state[captcha_name]: + return True + return False @classmethod - def generate_captcha(cls) -> tuple: + def generate_captcha(cls, captcha_name: str) -> ImageCaptcha: """ - Generates a captcha image. + Generates a captcha image and stores the associated captcha string in the + session state. + + Parameters + ---------- + captcha_name: str + Name of the generated captcha stored in the session state. Returns ------- - int - The randomly generated four digit captcha. ImageCaptcha - The randomly generated captcha object. + Randomly generated captcha image. """ image = ImageCaptcha(width=120, height=75) - random_digit = random.choices(string.digits, k=4) - return random_digit, image.generate(random_digit) - + if captcha_name not in st.session_state: + st.session_state[captcha_name] = ''.join(random.choices(string.digits, k=4)) + return image.generate(st.session_state[captcha_name]) + @classmethod + def generate_random_pw(cls, length: int=16) -> str: + """ + Generates a random password. + + Parameters + ---------- + length: int + Length of the returned password. + + Returns + ------- + str + Randomly generated password. + """ + letters = string.ascii_letters + string.digits + return ''.join(random.choice(letters) for i in range(length)).replace(' ','') diff --git a/streamlit_authenticator/utilities/validator.py b/streamlit_authenticator/utilities/validator.py index e8eeaaed..6134af87 100644 --- a/streamlit_authenticator/utilities/validator.py +++ b/streamlit_authenticator/utilities/validator.py @@ -28,8 +28,28 @@ def validate_email(self, email: str) -> bool: bool Validity of entered email. """ - pattern = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b" - return 2 < len(email) < 320 and bool(re.match(pattern, email)) + pattern = r"^[a-zA-Z0-9._%+-]{1,254}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,63}$" + return bool(re.match(pattern, email)) + def validate_length(self, variable: str, min_length: int=0, max_length: int=254) -> bool: + """ + Checks the length of a variable. + + Parameters + ---------- + variable: str + The variable to be validated. + min_length: str + The minimum required length for the variable. + max_length: str + The maximum required length for the variable. + + Returns + ------- + bool + Validity of entered variable. + """ + pattern = rf"^.{{{min_length},{max_length}}}$" + return bool(re.match(pattern, variable)) def validate_name(self, name: str) -> bool: """ Checks the validity of the entered name. @@ -44,27 +64,24 @@ def validate_name(self, name: str) -> bool: bool Validity of entered name. """ - pattern = r"^[A-Za-z ]+$" - return 1 <= len(name) <= 100 and bool(re.match(pattern, name)) - def validate_length(self, variable: str, min_length: int=0, max_length: int=100) -> bool: + pattern = r"^[A-Za-z. ]{2,100}$" + return bool(re.match(pattern, name)) + def validate_password(self, password: str) -> bool: """ - Checks the length of a variable. - + Checks the validity of the entered password. + Parameters ---------- - variable: str - The variable to be validated. - min_length: str - The minimum required length for the variable. - max_length: str - The maximum required length for the variable. - + password: str + The password to be validated. + Returns ------- bool - Validity of entered variable. + Validity of entered password. """ - return min_length <= len(variable) <= max_length + pattern = r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,20}$" + return bool(re.match(pattern, password)) def validate_username(self, username: str) -> bool: """ Checks the validity of the entered username. @@ -79,6 +96,5 @@ def validate_username(self, username: str) -> bool: bool Validity of entered username. """ - pattern = r"^[a-zA-Z0-9_-]{1,20}$" + 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})$" return bool(re.match(pattern, username)) - \ No newline at end of file diff --git a/streamlit_authenticator/views/__init__.py b/streamlit_authenticator/views/__init__.py new file mode 100644 index 00000000..3b86ad2b --- /dev/null +++ b/streamlit_authenticator/views/__init__.py @@ -0,0 +1 @@ +from .authentication_view import Authenticate diff --git a/streamlit_authenticator/authenticate/__init__.py b/streamlit_authenticator/views/authentication_view.py similarity index 50% rename from streamlit_authenticator/authenticate/__init__.py rename to streamlit_authenticator/views/authentication_view.py index 6b96aeba..bfd1fd30 100644 --- a/streamlit_authenticator/authenticate/__init__.py +++ b/streamlit_authenticator/views/authentication_view.py @@ -1,32 +1,29 @@ """ -Script description: This module renders and invokes the logic for the -login, logout, register user, reset password, forgot password, forgot username, -and modify user details widgets. +Script description: This module renders the login, logout, register user, reset password, +forgot password, forgot username, and modify user details widgets. Libraries imported: - time: Module implementing the sleep function. -- streamlit: Framework used to build pure Python web applications. - typing: Module implementing standard typing notations for Python functions. +- streamlit: Framework used to build pure Python web applications. """ import time -from typing import Optional +from typing import Callable, Dict, List, Optional import streamlit as st -from ..utilities.validator import Validator -from ..utilities.exceptions import DeprecationError - -from .cookie import CookieHandler -from .authentication import AuthenticationHandler +from .. import params +from ..controllers import AuthenticationController, CookieController +from ..utilities import Helpers, LogoutError, ResetError, UpdateError, Validator class Authenticate: """ - This class will create login, logout, register user, reset password, forgot password, + This class renders login, logout, register user, reset password, forgot password, forgot username, and modify user details widgets. """ def __init__(self, credentials: dict, cookie_name: str, cookie_key: str, - cookie_expiry_days: float=30.0, pre_authorized: Optional[list]=None, - validator: Optional[Validator]=None): + cookie_expiry_days: float=30.0, pre_authorized: Optional[List[str]]=None, + validator: Optional[Validator]=None, auto_hash: bool=True): """ Create a new instance of "Authenticate". @@ -42,20 +39,25 @@ def __init__(self, credentials: dict, cookie_name: str, cookie_key: str, cookie_expiry_days: float Number of days before the re-authentication cookie automatically expires on the client's browser. - pre-authorized: list + pre-authorized: list, optional List of emails of unregistered users who are authorized to register. - validator: Validator + validator: Validator, optional Validator object that checks the validity of the username, name, and email fields. + auto_hash: bool + Automatic hashing requirement for passwords, + True: plain text passwords will be automatically hashed, + False: plain text passwords will not be automatically hashed. """ - self.authentication_handler = AuthenticationHandler(credentials, - pre_authorized, - validator) - self.cookie_handler = CookieHandler(cookie_name, - cookie_key, - cookie_expiry_days) - - def forgot_password(self, location: str='main', fields: dict=None, - clear_on_submit: bool=False) -> tuple: + self.cookie_controller = CookieController(cookie_name, + cookie_key, + cookie_expiry_days) + self.authentication_controller = AuthenticationController(credentials, + pre_authorized, + validator, + auto_hash) + def forgot_password(self, location: str='main', fields: Optional[Dict[str, str]]=None, + captcha: bool=False, clear_on_submit: bool=False, + key: str='Forgot password', callback: Optional[Callable]=None) -> tuple: """ Creates a forgot password widget. @@ -63,10 +65,20 @@ def forgot_password(self, location: str='main', fields: dict=None, ---------- location: str Location of the forgot password widget i.e. main or sidebar. - fields: dict + fields: dict, optional Rendered names of the fields/buttons. + captcha: bool + Captcha requirement for the forgot password widget, + True: captcha required, + False: captcha removed. clear_on_submit: bool - Clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. + Clear on submit setting, + True: clears inputs on submit, + False: keeps inputs on submit. + key: str + Unique key provided to widget to avoid duplicate WidgetID errors. + callback: callable, optional + Optional callback function that will be invoked on form submission. Returns ------- @@ -78,30 +90,31 @@ def forgot_password(self, location: str='main', fields: dict=None, New plain text password that should be transferred to the user securely. """ if fields is None: - fields = {'Form name':'Forgot password', 'Username':'Username', 'Submit':'Submit'} + fields = {'Form name':'Forgot password', 'Username':'Username', 'Submit':'Submit', + 'Captcha':'Captcha'} if location not in ['main', 'sidebar']: - # Temporary deprecation error to be displayed until a future release - raise DeprecationError("""Likely deprecation error, the 'form_name' parameter has been - replaced with the 'fields' parameter. For further information - please refer to - https://github.com/mkhorasani/Streamlit-Authenticator/tree/main?tab=readme-ov-file#authenticateforgot_password""") - # raise ValueError("Location must be one of 'main' or 'sidebar'") + raise ValueError("Location must be one of 'main' or 'sidebar'") if location == 'main': - forgot_password_form = st.form('Forgot password', clear_on_submit=clear_on_submit) + forgot_password_form = st.form(key=key, clear_on_submit=clear_on_submit) elif location == 'sidebar': - forgot_password_form = st.sidebar.form('Forgot password') - + forgot_password_form = st.sidebar.form(key=key, clear_on_submit=clear_on_submit) forgot_password_form.subheader('Forget password' if 'Form name' not in fields else fields['Form name']) username = forgot_password_form.text_input('Username' if 'Username' not in fields - else fields['Username']).lower() - + else fields['Username']) + entered_captcha = None + if captcha: + entered_captcha = forgot_password_form.text_input('Captcha' if 'Captcha' not in fields + else fields['Captcha']) + forgot_password_form.image(Helpers.generate_captcha('forgot_password_captcha')) if forgot_password_form.form_submit_button('Submit' if 'Submit' not in fields else fields['Submit']): - return self.authentication_handler.forgot_password(username) + return self.authentication_controller.forgot_password(username, callback, + captcha, entered_captcha) return None, None, None - def forgot_username(self, location: str='main', fields: dict=None, - clear_on_submit: bool=False) -> tuple: + def forgot_username(self, location: str='main', fields: Optional[Dict[str, str]]=None, + captcha: bool=False, clear_on_submit: bool=False, + key: str='Forgot username', callback: Optional[Callable]=None) -> tuple: """ Creates a forgot username widget. @@ -109,10 +122,20 @@ def forgot_username(self, location: str='main', fields: dict=None, ---------- location: str Location of the forgot username widget i.e. main or sidebar. - fields: dict + fields: dict, optional Rendered names of the fields/buttons. + captcha: bool + Captcha requirement for the forgot username widget, + True: captcha required, + False: captcha removed. clear_on_submit: bool - Clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. + Clear on submit setting, + True: clears inputs on submit, + False: keeps inputs on submit. + key: str + Unique key provided to widget to avoid duplicate WidgetID errors. + callback: callable, optional + Optional callback function that will be invoked on form submission. Returns ------- @@ -122,93 +145,112 @@ def forgot_username(self, location: str='main', fields: dict=None, Email associated with the forgotten username. """ if fields is None: - fields = {'Form name':'Forgot username', 'Email':'Email', 'Submit':'Submit'} + fields = {'Form name':'Forgot username', 'Email':'Email', 'Submit':'Submit', + 'Captcha':'Captcha'} if location not in ['main', 'sidebar']: - # Temporary deprecation error to be displayed until a future release - raise DeprecationError("""Likely deprecation error, the 'form_name' parameter - has been replaced with the 'fields' parameter. For further - information please refer to - https://github.com/mkhorasani/Streamlit-Authenticator/tree/main?tab=readme-ov-file#authenticateforgot_username""") - # raise ValueError("Location must be one of 'main' or 'sidebar'") + raise ValueError("Location must be one of 'main' or 'sidebar'") if location == 'main': - forgot_username_form = st.form('Forgot username', clear_on_submit=clear_on_submit) + forgot_username_form = st.form(key=key, clear_on_submit=clear_on_submit) elif location == 'sidebar': - forgot_username_form = st.sidebar.form('Forgot username') - + forgot_username_form = st.sidebar.form(key=key, clear_on_submit=clear_on_submit) forgot_username_form.subheader('Forget username' if 'Form name' not in fields else fields['Form name']) email = forgot_username_form.text_input('Email' if 'Email' not in fields else fields['Email']) - + entered_captcha = None + if captcha: + entered_captcha = forgot_username_form.text_input('Captcha' if 'Captcha' not in fields + else fields['Captcha']) + forgot_username_form.image(Helpers.generate_captcha('forgot_username_captcha')) if forgot_username_form.form_submit_button('Submit' if 'Submit' not in fields else fields['Submit']): - return self.authentication_handler.forgot_username(email) + return self.authentication_controller.forgot_username(email, callback, + captcha, entered_captcha) return None, email def login(self, location: str='main', max_concurrent_users: Optional[int]=None, - max_login_attempts: Optional[int]=None, fields: dict=None, - clear_on_submit: bool=False) -> tuple: + max_login_attempts: Optional[int]=None, fields: Optional[Dict[str, str]]=None, + captcha: bool=False, clear_on_submit: bool=False, key: str='Login', + callback: Optional[Callable]=None, sleep_time: Optional[float]=None) -> tuple: """ Creates a login widget. Parameters ---------- location: str - Location of the login widget i.e. main or sidebar. - max_concurrent_users: int + Location of the logout button i.e. main, sidebar or unrendered. + max_concurrent_users: int, optional Maximum number of users allowed to login concurrently. - max_login_attempts: int + max_login_attempts: int, optional Maximum number of failed login attempts a user can make. - fields: dict + fields: dict, optional Rendered names of the fields/buttons. + captcha: bool + Captcha requirement for the login widget, + True: captcha required, + False: captcha removed. clear_on_submit: bool - Clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. + Clear on submit setting, + True: clears inputs on submit, + False: keeps inputs on submit. + key: str + Unique key provided to widget to avoid duplicate WidgetID errors. + callback: callable, optional + Optional callback function that will be invoked on form submission. + sleep_time: float, optional + Optional sleep time for the login widget. Returns ------- str Name of the authenticated user. bool - Status of authentication, None: no credentials entered, - False: incorrect credentials, True: correct credentials. + Status of authentication, + None: no credentials entered, + True: correct credentials, + False: incorrect credentials. str Username of the authenticated user. """ if fields is None: fields = {'Form name':'Login', 'Username':'Username', 'Password':'Password', - 'Login':'Login'} - if location not in ['main', 'sidebar']: - # Temporary deprecation error to be displayed until a future release - raise DeprecationError("""Likely deprecation error, the 'form_name' parameter has been - replaced with the 'fields' parameter. For further information please - refer to - https://github.com/mkhorasani/Streamlit-Authenticator/tree/main?tab=readme-ov-file#authenticatelogin""") - # raise ValueError("Location must be one of 'main' or 'sidebar'") + 'Login':'Login', 'Captcha':'Captcha'} + if location not in ['main', 'sidebar', 'unrendered']: + raise ValueError("Location must be one of 'main' or 'sidebar' or 'unrendered'") if not st.session_state['authentication_status']: - token = self.cookie_handler.get_cookie() + token = self.cookie_controller.get_cookie() if token: - self.authentication_handler.execute_login(token=token) - time.sleep(0.7) + self.authentication_controller.login(token=token) + time.sleep(params.LOGIN_SLEEP_TIME if sleep_time is None else sleep_time) if not st.session_state['authentication_status']: if location == 'main': - login_form = st.form('Login', clear_on_submit=clear_on_submit) + login_form = st.form(key=key, clear_on_submit=clear_on_submit) elif location == 'sidebar': - login_form = st.sidebar.form('Login') + login_form = st.sidebar.form(key=key, clear_on_submit=clear_on_submit) + elif location == 'unrendered': + return (st.session_state['name'], st.session_state['authentication_status'], + st.session_state['username']) login_form.subheader('Login' if 'Form name' not in fields else fields['Form name']) username = login_form.text_input('Username' if 'Username' not in fields - else fields['Username']).lower() + else fields['Username']) password = login_form.text_input('Password' if 'Password' not in fields else fields['Password'], type='password') + entered_captcha = None + if captcha: + entered_captcha = login_form.text_input('Captcha' if 'Captcha' not in fields + else fields['Captcha']) + login_form.image(Helpers.generate_captcha('login_captcha')) if login_form.form_submit_button('Login' if 'Login' not in fields else fields['Login']): - if self.authentication_handler.check_credentials(username, - password, - max_concurrent_users, - max_login_attempts): - self.authentication_handler.execute_login(username=username) - self.cookie_handler.set_cookie() + if self.authentication_controller.login(username, password, + max_concurrent_users, + max_login_attempts, + callback=callback, captcha=captcha, + entered_captcha=entered_captcha): + self.cookie_controller.set_cookie() return (st.session_state['name'], st.session_state['authentication_status'], st.session_state['username']) - def logout(self, button_name: str='Logout', location: str='main', key: Optional[str]=None): + def logout(self, button_name: str='Logout', location: str='main', key: str='Logout', + callback: Optional[Callable]=None): """ Creates a logout button. @@ -217,27 +259,36 @@ def logout(self, button_name: str='Logout', location: str='main', key: Optional[ button_name: str Rendered name of the logout button. location: str - Location of the logout button i.e. main or sidebar or unrendered. + Location of the logout button i.e. main, sidebar or unrendered. key: str Unique key to be used in multi-page applications. + callback: callable, optional + Optional callback function that will be invoked on submission. """ - if location not in ['main', 'sidebar','unrendered']: + if not st.session_state['authentication_status']: + raise LogoutError('User must be logged in to use the logout button') + if location not in ['main', 'sidebar', 'unrendered']: raise ValueError("Location must be one of 'main' or 'sidebar' or 'unrendered'") if location == 'main': - if st.button(button_name, key): - self.authentication_handler.execute_logout() - self.cookie_handler.delete_cookie() + if st.button(button_name, key=key): + self.authentication_controller.logout() + self.cookie_controller.delete_cookie() + if callback: + callback({}) elif location == 'sidebar': - if st.sidebar.button(button_name, key): - self.authentication_handler.execute_logout() - self.cookie_handler.delete_cookie() + if st.sidebar.button(button_name, key=key): + self.authentication_controller.logout() + self.cookie_controller.delete_cookie() + if callback: + callback({}) elif location == 'unrendered': if st.session_state['authentication_status']: - self.authentication_handler.execute_logout() - self.cookie_handler.delete_cookie() + self.authentication_controller.logout() + self.cookie_controller.delete_cookie() def register_user(self, location: str='main', pre_authorization: bool=True, - domains: Optional[list]=None, fields: dict=None, - clear_on_submit: bool=False) -> tuple: + domains: Optional[List[str]]=None, fields: Optional[Dict[str, str]]=None, + captcha: bool=True, clear_on_submit: bool=False, key: str='Register user', + callback: Optional[Callable]=None) -> tuple: """ Creates a register new user widget. @@ -246,15 +297,27 @@ def register_user(self, location: str='main', pre_authorization: bool=True, location: str Location of the register new user widget i.e. main or sidebar. pre-authorization: bool - Pre-authorization requirement, True: user must be pre-authorized to register, + Pre-authorization requirement, + True: user must be pre-authorized to register, False: any user can register. - domains: list + domains: list, optional Required list of domains a new email must belong to i.e. ['gmail.com', 'yahoo.com'], - list: required list of domains, None: any domain is allowed. - fields: dict + list: required list of domains, + None: any domain is allowed. + fields: dict, optional Rendered names of the fields/buttons. + captcha: bool + Captcha requirement for the register user widget, + True: captcha required, + False: captcha removed. clear_on_submit: bool - Clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. + Clear on submit setting, + True: clears inputs on submit, + False: keeps inputs on submit. + key: str + Unique key provided to widget to avoid duplicate WidgetID errors. + callback: callable, optional + Optional callback function that will be invoked on form submission. Returns ------- @@ -268,44 +331,43 @@ def register_user(self, location: str='main', pre_authorization: bool=True, if fields is None: fields = {'Form name':'Register user', 'Email':'Email', 'Username':'Username', 'Password':'Password', 'Repeat password':'Repeat password', - 'Register':'Register'} - if pre_authorization: - if not self.authentication_handler.pre_authorized: - raise ValueError("pre-authorization argument must not be None") + 'Register':'Register', 'Captcha':'Captcha'} if location not in ['main', 'sidebar']: - # Temporary deprecation error to be displayed until a future release - raise DeprecationError("""Likely deprecation error, the 'form_name' parameter has - been replaced with the 'fields' parameter. For further - information please refer to - https://github.com/mkhorasani/Streamlit-Authenticator/tree/main?tab=readme-ov-file#authenticateregister_user""") - # raise ValueError("Location must be one of 'main' or 'sidebar'") + raise ValueError("Location must be one of 'main' or 'sidebar'") if location == 'main': - register_user_form = st.form('Register user', clear_on_submit=clear_on_submit) + register_user_form = st.form(key=key, clear_on_submit=clear_on_submit) elif location == 'sidebar': - register_user_form = st.sidebar.form('Register user') - - register_user_form.subheader('Register User' if 'Form name' not in fields + register_user_form = st.sidebar.form(key=key, clear_on_submit=clear_on_submit) + register_user_form.subheader('Register user' if 'Form name' not in fields else fields['Form name']) new_name = register_user_form.text_input('Name' if 'Name' not in fields else fields['Name']) new_email = register_user_form.text_input('Email' if 'Email' not in fields else fields['Email']) new_username = register_user_form.text_input('Username' if 'Username' not in fields - else fields['Username']).lower() + else fields['Username']) new_password = register_user_form.text_input('Password' if 'Password' not in fields - else fields['Password'], type='password') + else fields['Password'], + type='password') new_password_repeat = register_user_form.text_input('Repeat password' if 'Repeat password' not in fields else fields['Repeat password'], type='password') + entered_captcha = None + if captcha: + entered_captcha = register_user_form.text_input('Captcha' if 'Captcha' not in fields + else fields['Captcha']).strip() + register_user_form.image(Helpers.generate_captcha('register_user_captcha')) if register_user_form.form_submit_button('Register' if 'Register' not in fields else fields['Register']): - return self.authentication_handler.register_user(new_password, new_password_repeat, - pre_authorization, new_username, - new_name, new_email, domains) + return self.authentication_controller.register_user(new_name, new_email, new_username, + new_password, new_password_repeat, + pre_authorization, domains, + callback, captcha, entered_captcha) return None, None, None - def reset_password(self, username: str, location: str='main', fields: dict=None, - clear_on_submit: bool=False) -> bool: + def reset_password(self, username: str, location: str='main', + fields: Optional[Dict[str, str]]=None, clear_on_submit: bool=False, + key: str='Reset password', callback: Optional[Callable]=None) -> bool: """ Creates a password reset widget. @@ -315,54 +377,59 @@ def reset_password(self, username: str, location: str='main', fields: dict=None, Username of the user to reset the password for. location: str Location of the password reset widget i.e. main or sidebar. - fields: dict + fields: dict, optional Rendered names of the fields/buttons. clear_on_submit: bool - Clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. + Clear on submit setting, + True: clears inputs on submit, + False: keeps inputs on submit. + key: str + Unique key provided to widget to avoid duplicate WidgetID errors. + callback: callable, optional + Optional callback function that will be invoked on form submission. Returns ------- bool Status of resetting the password. """ + if not st.session_state['authentication_status']: + raise ResetError('User must be logged in to use the reset password widget') if fields is None: fields = {'Form name':'Reset password', 'Current password':'Current password', 'New password':'New password','Repeat password':'Repeat password', 'Reset':'Reset'} if location not in ['main', 'sidebar']: - # Temporary deprecation error to be displayed until a future release - raise DeprecationError("""Likely deprecation error, the 'form_name' parameter has - been replaced with the 'fields' parameter. For further - information please refer to - https://github.com/mkhorasani/Streamlit-Authenticator/tree/main?tab=readme-ov-file#authenticatereset_password""") - # raise ValueError("Location must be one of 'main' or 'sidebar'") + raise ValueError("Location must be one of 'main' or 'sidebar'") if location == 'main': - reset_password_form = st.form('Reset password', clear_on_submit=clear_on_submit) + reset_password_form = st.form(key=key, clear_on_submit=clear_on_submit) elif location == 'sidebar': - reset_password_form = st.sidebar.form('Reset password') + reset_password_form = st.sidebar.form(key=key, clear_on_submit=clear_on_submit) reset_password_form.subheader('Reset password' if 'Form name' not in fields else fields['Form name']) username = username.lower() password = reset_password_form.text_input('Current password' if 'Current password' not in fields else fields['Current password'], - type='password') + type='password').strip() new_password = reset_password_form.text_input('New password' if 'New password' not in fields else fields['New password'], - type='password') + type='password').strip() new_password_repeat = reset_password_form.text_input('Repeat password' if 'Repeat password' not in fields else fields['Repeat password'], - type='password') + type='password').strip() if reset_password_form.form_submit_button('Reset' if 'Reset' not in fields else fields['Reset']): - if self.authentication_handler.reset_password(username, password, new_password, - new_password_repeat): + if self.authentication_controller.reset_password(username, password, new_password, + new_password_repeat, callback): return True return None - def update_user_details(self, username: str, location: str='main', fields: dict=None, - clear_on_submit: bool=False) -> bool: + def update_user_details(self, username: str, location: str='main', + fields: Optional[Dict[str, str]]=None, + clear_on_submit: bool=False, key: str='Update user details', + callback: Optional[Callable]=None) -> bool: """ Creates a update user details widget. @@ -372,31 +439,33 @@ def update_user_details(self, username: str, location: str='main', fields: dict= Username of the user to update user details for. location: str Location of the update user details widget i.e. main or sidebar. - fields: dict + fields: dict, optional Rendered names of the fields/buttons. clear_on_submit: bool - Clear on submit setting, True: clears inputs on submit, False: keeps inputs on submit. + Clear on submit setting, + True: clears inputs on submit, + False: keeps inputs on submit. + key: str + Unique key provided to widget to avoid duplicate WidgetID errors. + callback: callable, optional + Optional callback function that will be invoked on form submission. Returns ------- bool Status of updating the user details. """ + if not st.session_state['authentication_status']: + raise UpdateError('User must be logged in to use the update user details widget') if fields is None: fields = {'Form name':'Update user details', 'Field':'Field', 'Name':'Name', 'Email':'Email', 'New value':'New value', 'Update':'Update'} if location not in ['main', 'sidebar']: - # Temporary deprecation error to be displayed until a future release - raise DeprecationError("""Likely deprecation error, the 'form_name' parameter - has been replaced with the 'fields' parameter. For further - information please refer to - https://github.com/mkhorasani/Streamlit-Authenticator/tree/main?tab=readme-ov-file#authenticateupdate_user_details""") - # raise ValueError("Location must be one of 'main' or 'sidebar'") + raise ValueError("Location must be one of 'main' or 'sidebar'") if location == 'main': - update_user_details_form = st.form('Update user details', - clear_on_submit=clear_on_submit) + update_user_details_form = st.form(key=key, clear_on_submit=clear_on_submit) elif location == 'sidebar': - update_user_details_form = st.sidebar.form('Update user details') + update_user_details_form = st.sidebar.form(key=key, clear_on_submit=clear_on_submit) update_user_details_form.subheader('Update user details' if 'Form name' not in fields else fields['Form name']) username = username.lower() @@ -406,13 +475,14 @@ def update_user_details(self, username: str, location: str='main', fields: dict= else fields['Field'], update_user_details_form_fields) new_value = update_user_details_form.text_input('New value' if 'New value' not in fields - else fields['New value']) + else fields['New value']).strip() if update_user_details_form_fields.index(field) == 0: field = 'name' elif update_user_details_form_fields.index(field) == 1: field = 'email' if update_user_details_form.form_submit_button('Update' if 'Update' not in fields else fields['Update']): - if self.authentication_handler.update_user_details(new_value, username, field): - self.cookie_handler.set_cookie() + if self.authentication_controller.update_user_details(new_value, username, field, + callback): + self.cookie_controller.set_cookie() return True diff --git a/tests/streamlit_authenticator_test.py b/tests/streamlit_authenticator_test.py index 7ea7dc47..d72b026d 100644 --- a/tests/streamlit_authenticator_test.py +++ b/tests/streamlit_authenticator_test.py @@ -1,18 +1,30 @@ +""" +Script description: This script imports tests the Streamlit-Authenticator package. + +Libraries imported: +- yaml: Module implementing the data serialization used for human readable documents. +- streamlit: Framework used to build pure Python web applications. +""" + import yaml import streamlit as st from yaml.loader import SafeLoader import streamlit_authenticator as stauth -from streamlit_authenticator.utilities.exceptions import (CredentialsError, - ForgotError, - LoginError, - RegisterError, - ResetError, - UpdateError) +from streamlit_authenticator.utilities import (CredentialsError, + ForgotError, + Hasher, + LoginError, + RegisterError, + ResetError, + UpdateError) # Loading config file with open('../config.yaml', 'r', encoding='utf-8') as file: config = yaml.load(file, Loader=SafeLoader) +# Hashing all plain text passwords once +# Hasher.hash_passwords(config['credentials']) + # Creating the authenticator object authenticator = stauth.Authenticate( config['credentials'], @@ -47,21 +59,21 @@ except CredentialsError as e: st.error(e) -# # Creating a new user registration widget +# Creating a new user registration widget try: (email_of_registered_user, - username_of_registered_user, - name_of_registered_user) = authenticator.register_user(pre_authorization=False) + username_of_registered_user, + name_of_registered_user) = authenticator.register_user(pre_authorization=False) if email_of_registered_user: st.success('User registered successfully') except RegisterError as e: st.error(e) -# # Creating a forgot password widget +# Creating a forgot password widget try: (username_of_forgotten_password, - email_of_forgotten_password, - new_random_password) = authenticator.forgot_password() + email_of_forgotten_password, + new_random_password) = authenticator.forgot_password() if username_of_forgotten_password: st.success('New password sent securely') # Random password to be transferred to the user securely @@ -70,10 +82,10 @@ except ForgotError as e: st.error(e) -# # Creating a forgot username widget +# Creating a forgot username widget try: (username_of_forgotten_username, - email_of_forgotten_username) = authenticator.forgot_username() + email_of_forgotten_username) = authenticator.forgot_username() if username_of_forgotten_username: st.success('Username sent securely') # Username to be transferred to the user securely @@ -82,7 +94,7 @@ except ForgotError as e: st.error(e) -# # Creating an update user details widget +# Creating an update user details widget if st.session_state["authentication_status"]: try: if authenticator.update_user_details(st.session_state["username"]):