diff --git a/README.md b/README.md index ec106c0..0e5486b 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,9 @@ Follow the instructions [here](https://wave.h2o.ai/docs/installation) to downloa **Details:** This application pulls tweets and uses the open source VaderSentiment to understand positive and negative tweets +### [JWT-Auth](jwt-auth/README.md) + +**Details:** This is an example on how to add JWT-based authentication to a h2o wave app. ### [Employee Churn - Step by Step Training Apps](emp-churn-step-by-step/README.md) diff --git a/jwt-auth/README.md b/jwt-auth/README.md new file mode 100644 index 0000000..946c7d8 --- /dev/null +++ b/jwt-auth/README.md @@ -0,0 +1,48 @@ +# Wave app with JWT authentication +This is an example on how to add authentication to a h2o wave app. It builds on a `wave init` example to demonstrate how non-authenticated users will not have access to the application. It also serves as a demonstration how to control and customize routing flow in a wave app. + +Using OpenID Connect is a safer way to provide authentication to your users. Check the instructions for use with OpenID Connect [here](https://wave.h2o.ai/docs/security#single-sign-on) and how to set up keycloak [here](https://wave.h2o.ai/docs/development/#using-openid-connect). Keycloak via docker is a very easy way to set up your own OpenID Connect provider. + +## Setup +Run `pip install -r requirements.txt` to install all dependencies. + +This example uses mongodb as a database for storing the credentials (username and hashed password). I've been using the `mongodb-community-server` docker image via docker desktop (https://hub.docker.com/r/mongodb/mongodb-community-server). + +## Run +Ensure that the database is running and reachable. + +Use `python user_register.py` to create a new user. + +Use `python user_auth.py` if you want to verify that the credentials work. + +In this directory, run `wave run app`. Enter your user credentials to log in. By default, the login will stay valid for 2h (default, unless `Remember me` is selected). During this time you can open the app in multiple browser tabs without the need of logging in again. Closing the browser will not reset the token as well (unless you have settings that clear all cookies and whatnot). The session will be reset if the wave app or the wave server are restarted (e.g. if auto-reload is enabled). + +To log out, open the header menu in the top-right corner and click `Logout`. + +## Details +- Hides the header and the sidebar from unauthorized users +- User password is stored as hashed password +- Uses [python-jose with cryptography](https://pypi.org/project/python-jose/) for token creation and [passlib with bcrypt](https://pypi.org/project/passlib/) for password hashing +- Token is stored in user-level. While logged in, any new tab will load without need for authentication. Once logged out, already loaded pages will remain loaded but upon refresh, the user is rerouted to the login page +- The user can choose to go for a token without expiration date which lets them stay logged in until the session data is deleted. +- The JWT is stored in `q.user.secret` + +![before_login.png](img/before_login.png) + +![after_login.png](img/after_login.png) + +## How to use in your own code +The `wave_auth.py` file contains the relevant code. Replace the example secret key with your own secret key. You can generate one with `openssl rand -hex 32`. + +If you want to use a different database then mongodb, implement an interface according to the example in `mongodb_layer.py` and replace the import in `wave_auth.py`. + +In `app.py`, the default `init()` and the `serve()` function were adjusted to support authentication based routing: +- Header and sidebar are still defined in `init()` but only populated after successful login. Function to fill and clear the header and sidebar are in `wave_auth.py` +- Extra zone for centered login box (`centered`) +- The auth based routing is implemented in `handle_auth_on()` which wraps the default `h2o_wave.routing.handle_on()` function. This should allow to write pages just as with regular routing. + + +## References +This project was bootstrapped with `wave init` -> `App with header & sidebar + navigation` command. + +The JWT authentication implementation follows the [fastapi tutorial](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/) on OAuth2 with Password and JWT Bearer tokens. diff --git a/jwt-auth/app.py b/jwt-auth/app.py new file mode 100644 index 0000000..b93d756 --- /dev/null +++ b/jwt-auth/app.py @@ -0,0 +1,240 @@ +from h2o_wave import main, app, Q, ui, on, data + +from util import add_card, clear_cards +from wave_auth import handle_auth_on + + +@on('#page1') +async def page1(q: Q): + clear_cards(q) + print("Loading page1") + q.page['sidebar'].value = '#page1' + + for i in range(3): + add_card(q, f'info{i}', ui.tall_info_card(box='horizontal', name='', title='Speed', + caption='The models are performant thanks to...', icon='SpeedHigh')) + add_card(q, 'article', ui.tall_article_preview_card( + box=ui.box('vertical', height='600px'), title='How does magic work', + image='https://images.pexels.com/photos/624015/pexels-photo-624015.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1', + content=''' +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum ac sodales felis. Duis orci enim, iaculis at augue vel, mattis imperdiet ligula. Sed a placerat lacus, vitae viverra ante. Duis laoreet purus sit amet orci lacinia, non facilisis ipsum venenatis. Duis bibendum malesuada urna. Praesent vehicula tempor volutpat. In sem augue, blandit a tempus sit amet, tristique vehicula nisl. Duis molestie vel nisl a blandit. Nunc mollis ullamcorper elementum. +Donec in erat augue. Nullam mollis ligula nec massa semper, laoreet pellentesque nulla ullamcorper. In ante ex, tristique et mollis id, facilisis non metus. Aliquam neque eros, semper id finibus eu, pellentesque ac magna. Aliquam convallis eros ut erat mollis, sit amet scelerisque ex pretium. Nulla sodales lacus a tellus molestie blandit. Praesent molestie elit viverra, congue purus vel, cursus sem. Donec malesuada libero ut nulla bibendum, in condimentum massa pretium. Aliquam erat volutpat. Interdum et malesuada fames ac ante ipsum primis in faucibus. Integer vel tincidunt purus, congue suscipit neque. Fusce eget lacus nibh. Sed vestibulum neque id erat accumsan, a faucibus leo malesuada. Curabitur varius ligula a velit aliquet tincidunt. Donec vehicula ligula sit amet nunc tempus, non fermentum odio rhoncus. +Vestibulum condimentum consectetur aliquet. Phasellus mollis at nulla vel blandit. Praesent at ligula nulla. Curabitur enim tellus, congue id tempor at, malesuada sed augue. Nulla in justo in libero condimentum euismod. Integer aliquet, velit id convallis maximus, nisl dui porta velit, et pellentesque ligula lorem non nunc. Sed tincidunt purus non elit ultrices egestas quis eu mauris. Sed molestie vulputate enim, a vehicula nibh pulvinar sit amet. Nullam auctor sapien est, et aliquet dui congue ornare. Donec pulvinar scelerisque justo, nec scelerisque velit maximus eget. Ut ac lectus velit. Pellentesque bibendum ex sit amet cursus commodo. Fusce congue metus at elementum ultricies. Suspendisse non rhoncus risus. In hac habitasse platea dictumst. + ''' + )) + + +@on('#page2') +async def page2(q: Q): + clear_cards(q) + q.page['sidebar'].value = '#page2' + add_card(q, 'chart1', ui.plot_card( + box='horizontal', + title='Chart 1', + data=data('category country product price', 10, rows=[ + ('G1', 'USA', 'P1', 124), + ('G1', 'China', 'P2', 580), + ('G1', 'USA', 'P3', 528), + ('G1', 'China', 'P1', 361), + ('G1', 'USA', 'P2', 228), + ('G2', 'China', 'P3', 418), + ('G2', 'USA', 'P1', 824), + ('G2', 'China', 'P2', 539), + ('G2', 'USA', 'P3', 712), + ('G2', 'USA', 'P1', 213), + ]), + plot=ui.plot([ui.mark(type='interval', x='=product', y='=price', color='=country', stack='auto', + dodge='=category', y_min=0)]) + )) + add_card(q, 'chart2', ui.plot_card( + box='horizontal', + title='Chart 2', + data=data('date price', 10, rows=[ + ('2020-03-20', 124), + ('2020-05-18', 580), + ('2020-08-24', 528), + ('2020-02-12', 361), + ('2020-03-11', 228), + ('2020-09-26', 418), + ('2020-11-12', 824), + ('2020-12-21', 539), + ('2020-03-18', 712), + ('2020-07-11', 213), + ]), + plot=ui.plot([ui.mark(type='line', x_scale='time', x='=date', y='=price', y_min=0)]) + )) + add_card(q, 'table', ui.form_card(box='vertical', items=[ui.table( + name='table', + downloadable=True, + resettable=True, + groupable=True, + columns=[ + ui.table_column(name='text', label='Process', searchable=True), + ui.table_column(name='tag', label='Status', filterable=True, cell_type=ui.tag_table_cell_type( + name='tags', + tags=[ + ui.tag(label='FAIL', color='$red'), + ui.tag(label='DONE', color='#D2E3F8', label_color='#053975'), + ui.tag(label='SUCCESS', color='$mint'), + ] + )) + ], + rows=[ + ui.table_row(name='row1', cells=['Process 1', 'FAIL']), + ui.table_row(name='row2', cells=['Process 2', 'SUCCESS,DONE']), + ui.table_row(name='row3', cells=['Process 3', 'DONE']), + ui.table_row(name='row4', cells=['Process 4', 'FAIL']), + ui.table_row(name='row5', cells=['Process 5', 'SUCCESS,DONE']), + ui.table_row(name='row6', cells=['Process 6', 'DONE']), + ]) + ])) + + +@on('#page3') +async def page3(q: Q): + clear_cards(q) + q.page['sidebar'].value = '#page3' + for i in range(12): + add_card(q, f'item{i}', ui.wide_info_card(box=ui.box('grid', width='400px'), name='', title='Tile', + caption='Lorem ipsum dolor sit amet')) + + +@on('#page4') +async def handle_page4(q: Q): + clear_cards(q, ['form']) + # When routing, drop all the cards except of the main ones (header, sidebar, meta). + # Since this page is interactive, we want to update its card instead of recreating it every time, so ignore 'form' card on drop. + q.page['sidebar'].value = '#page4' + + if q.args.step1: + # Just update the existing card, do not recreate. + q.page['form'].items = [ + ui.stepper(name='stepper', items=[ + ui.step(label='Step 1'), + ui.step(label='Step 2'), + ui.step(label='Step 3'), + ]), + ui.textbox(name='textbox2', label='Textbox 1'), + ui.buttons(justify='end', items=[ + ui.button(name='step2', label='Next', primary=True), + ]) + ] + elif q.args.step2: + # Just update the existing card, do not recreate. + q.page['form'].items = [ + ui.stepper(name='stepper', items=[ + ui.step(label='Step 1', done=True), + ui.step(label='Step 2'), + ui.step(label='Step 3'), + ]), + ui.textbox(name='textbox2', label='Textbox 2'), + ui.buttons(justify='end', items=[ + ui.button(name='step1', label='Cancel'), + ui.button(name='step3', label='Next', primary=True), + ]) + ] + elif q.args.step3: + # Just update the existing card, do not recreate. + q.page['form'].items = [ + ui.stepper(name='stepper', items=[ + ui.step(label='Step 1', done=True), + ui.step(label='Step 2', done=True), + ui.step(label='Step 3'), + ]), + ui.textbox(name='textbox3', label='Textbox 3'), + ui.buttons(justify='end', items=[ + ui.button(name='step2', label='Cancel'), + ui.button(name='submit', label='Next', primary=True), + ]) + ] + else: + # If first time on this page, create the card. + add_card(q, 'form', ui.form_card(box='vertical', items=[ + ui.stepper(name='stepper', items=[ + ui.step(label='Step 1'), + ui.step(label='Step 2'), + ui.step(label='Step 3'), + ]), + ui.textbox(name='textbox1', label='Textbox 1'), + ui.buttons(justify='end', items=[ + ui.button(name='step2', label='Next', primary=True), + ]), + ])) + + +async def init(q: Q) -> None: + q.page['meta'] = ui.meta_card(box='', layouts=[ui.layout(breakpoint='xs', min_height='100vh', zones=[ + ui.zone('main', size='1', direction=ui.ZoneDirection.ROW, zones=[ + ui.zone('sidebar', size='250px'), + ui.zone('body', zones=[ + ui.zone('header'), + ui.zone('content', zones=[ + # Specify various zones and use the one that is currently needed. Empty zones are ignored. + ui.zone('horizontal', size='1', direction=ui.ZoneDirection.ROW), + ui.zone('centered', size='1 1 1 1', align='center'), + ui.zone('vertical', size='1'), + ui.zone('grid', direction=ui.ZoneDirection.ROW, wrap='stretch', justify='center') + ]), + ]), + ]) + ])]) + q.page['sidebar'] = ui.nav_card( + box='sidebar', color='primary', title='My App', subtitle="Let's conquer the world!", + value=f'#{q.args["#"]}' if q.args['#'] else '#page1', + image='https://wave.h2o.ai/img/h2o-logo.svg', items=[]) + q.page['header'] = ui.header_card( + box='header', title='', subtitle='', + ) + # If no active hash present, render page1. + if q.args['#'] is None: + await page1(q) + + +@on('#profile') +async def profile(q: Q): + """Example of a profile page""" + clear_cards(q) + q.page['sidebar'].value = '' + + add_card(q, 'profile-card', ui.form_card('vertical', items=[ + ui.text_l(f"**Username**: {q.user.username}"), + ui.text_l(f"**Role**: User") + ])) + if q.args.change_password: + add_card(q, 'edit-password-card', ui.form_card('vertical', items=[ + ui.text_l("DUMMY FORM. FOR VISUAL DEMONSTRATION ONLY."), + ui.textbox('old_password', 'Old Password', password=True), + ui.textbox('new_password_one', 'New Password', password=True), + ui.textbox('new_password_two', 'New Password (Repeat)', password=True), + ui.button('confirm_change_password', 'Confirm change', primary=True), + ])) + elif q.args.confirm_change_password: + # TODO: compare passwords + # TODO: verify old password + # TODO: Only if both are successful may a password change be submitted + add_card(q, 'edit-password-card', ui.form_card('vertical', items=[ + ui.text_l("[DUMMY MESSAGE]") + ])) + else: + add_card(q, 'password-card', ui.form_card('vertical', items=[ + ui.button('change_password', 'Change password'), + ])) + + +async def initialize_client(q: Q): + q.client.cards = set() + await init(q) + q.client.initialized = True + + +@app('/') +async def serve(q: Q): + if not q.client.initialized: + # Run only once per client connection (e.g. new tabs by the same user). + q.client.cards = set() + await init(q) + q.client.initialized = True + q.client.new = True # Indicate that client connected for the first time + + await handle_auth_on(q, home_page=page1) + await q.page.save() diff --git a/jwt-auth/img/after_login.png b/jwt-auth/img/after_login.png new file mode 100644 index 0000000..dc5ef5f Binary files /dev/null and b/jwt-auth/img/after_login.png differ diff --git a/jwt-auth/img/before_login.png b/jwt-auth/img/before_login.png new file mode 100644 index 0000000..9f737d0 Binary files /dev/null and b/jwt-auth/img/before_login.png differ diff --git a/jwt-auth/mongodb_layer.py b/jwt-auth/mongodb_layer.py new file mode 100644 index 0000000..62b0d3e --- /dev/null +++ b/jwt-auth/mongodb_layer.py @@ -0,0 +1,36 @@ +"""Basic mongodb interface to store and retrieve user credentials""" +from mongoengine import connect +from mongoengine import Document, StringField, errors + +connection = connect(db="wave-app", host="localhost", port=27017) + + +class Credentials(Document): + user = StringField(required=True, unique=True) + hashed_pw = StringField(required=True) + + +def create_user(user: str, hashed_pw: str): + new_user = Credentials(user=user, hashed_pw=hashed_pw) + try: + new_user.save() + except (errors.ValidationError, errors.OperationError) as e: + print(e) + return False + return True + + +def has_user(user: str): + user_obj = Credentials.objects(user=user) + return len(user_obj) != 0 + + +def get_hashed_pw(user: str): + user_obj = Credentials.objects(user=user) + if len(user_obj) == 0: + print("Could not find user", user) + elif len(user_obj) > 1: + print("More than 1 user found:", user) + else: + return user_obj[0].hashed_pw + return None diff --git a/jwt-auth/requirements.txt b/jwt-auth/requirements.txt new file mode 100644 index 0000000..be72139 --- /dev/null +++ b/jwt-auth/requirements.txt @@ -0,0 +1,4 @@ +h2o-wave==0.25.2 +mongoengine +python-jose[cryptography] +passlib[bcrypt] \ No newline at end of file diff --git a/jwt-auth/user_auth.py b/jwt-auth/user_auth.py new file mode 100644 index 0000000..0df1906 --- /dev/null +++ b/jwt-auth/user_auth.py @@ -0,0 +1,35 @@ +""" +Basic CLI script to test the wave_auth backend +""" +from getpass import getpass + +from wave_auth import get_secret, check_secret + + +class User: + def __init__(self, secret: str): + self.secret = secret + + +class QDummy: + def __init__(self, secret: str): + self.user = User(secret) + + +if __name__ == '__main__': + user = "" + while user == "": + user = input("Enter username: ") + password = "" + while password == "": + password = getpass("Enter password: ") + if password == "": + print("Error: Password may not be empty") + if len(password) < 4: + print("Error: Password must be at least 4 characters long") + + secret = get_secret(user, password) + print("Got JWT:", secret) + + q_dummy = QDummy(secret) + print("JWT valid:", check_secret(q_dummy)) diff --git a/jwt-auth/user_register.py b/jwt-auth/user_register.py new file mode 100644 index 0000000..7a542dc --- /dev/null +++ b/jwt-auth/user_register.py @@ -0,0 +1,19 @@ +""" +Basic CLI script to register a new user via the wave_auth interface. +""" +from getpass import getpass + +from mongodb_layer import create_user +from wave_auth import get_password_hash + + +if __name__ == '__main__': + user = "" + while user == "": + user = input("Enter username: ") + hash_pw = "" + while hash_pw == "": + hash_pw = get_password_hash(getpass("Enter password: ")) + + create_user(user, hash_pw) + print("Created user:", user) diff --git a/jwt-auth/util.py b/jwt-auth/util.py new file mode 100644 index 0000000..47e9dd9 --- /dev/null +++ b/jwt-auth/util.py @@ -0,0 +1,20 @@ +from h2o_wave import Q +from typing import Optional, List + + +def add_card(q: Q, name, card) -> None: + q.client.cards.add(name) + q.page[name] = card + + +# Remove all the cards related to navigation. +def clear_cards(q: Q, ignore: Optional[List[str]] = []) -> None: + print("Clearing cards") + if not q.client.cards: + print("No cards") + return + + for name in q.client.cards.copy(): + if name not in ignore: + del q.page[name] + q.client.cards.remove(name) diff --git a/jwt-auth/wave_auth.py b/jwt-auth/wave_auth.py new file mode 100644 index 0000000..0e7f86b --- /dev/null +++ b/jwt-auth/wave_auth.py @@ -0,0 +1,180 @@ +"""Demonstrator for authentication with h2o wave and JWT. + +Replace print statements with logging of your choice or drop them completely. +""" +from datetime import datetime, timedelta +from typing import Callable, Any, Awaitable + +from h2o_wave import Q, ui, on, handle_on + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from mongodb_layer import get_hashed_pw, has_user # Use whatever database layer to query this data +from util import add_card, clear_cards + + +# Create with openssl rand -hex 32 +# TODO: Replace with your own secret key!!!! +SECRET_KEY = "e662a467b5d75f28652d0cd110505164db8ef412cfbd15a9bce1e5b2425edd3f" +ALGORITHM = "HS256" +# Arbitrary timeout +ACCESS_TOKEN_EXPIRE_MINUTES = 120 + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def check_secret(q: Q): + """Validate the secret. + + This should check the JWT on authenticity and timestamp validity. + """ + if q.user.secret is None: + return False + try: + payload = jwt.decode(q.user.secret, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload["user"] + if payload["expires"] is not None: + expires = datetime.fromisoformat(payload["expires"]) + else: + expires = None + print(f"Username: {username} | expires: {expires}") + except JWTError: + print("Could not validate credentials") + return False + else: + if not has_user(username): + print("Unknown user") + return False + if expires is not None and expires < datetime.utcnow(): + print("Token expired") + return False + return True + + +def get_password_hash(password: str): + """Hash the password. Used to register a user""" + return pwd_context.hash(password) + + +def get_secret(user: str, password: str, stay_logged_in: bool = False): + """Validate credentials and return secret. + + This should safely validate the credentials against a data storage. + If valid, create a JWT and return + """ + hashed_pw = get_hashed_pw(user) + if hashed_pw is None: + print("No credentials found for user") + return None + + if pwd_context.verify(password, hashed_pw): + if stay_logged_in: + expire = None + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + expire = expire.isoformat() + to_encode = {"user": user, "expires": expire} + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + else: + print("Password invalid") + return None + + +async def handle_auth_on(q: Q, home_page: Callable[[Q], Awaitable[Any]]): + """Wrap default handle on with a secret check. + + Only handle routing if secret is valid. After successful login, loads home_page. + """ + if not check_secret(q): + if q.args.login: + await login(q, home_page=home_page) + else: + # Display login page for invalid secret + await login_page(q) + else: + if q.client.new: + # Load header and sidebar on first client connection + make_header_and_sidebar(q) + q.client.new = False + if q.args.logout: + await logout(q) + else: + await handle_on(q) + + +def make_header_and_sidebar(q: Q): + """Populate header and sidebar + + Header and sidebar should only be visible when user is logged in.""" + q.page["header"].items = [ui.menu([ + ui.command(name="#profile", icon="Contact", label="Profile"), + ui.command(name='logout', icon="Leave", label='Logout'), + ])] + + q.page['sidebar'].items = [ + ui.nav_group('Menu', items=[ + ui.nav_item(name='#page1', label='Home'), + ui.nav_item(name='#page2', label='Charts'), + ui.nav_item(name='#page3', label='Grid'), + ui.nav_item(name='#page4', label='Form'), + ]), + ] + + +def clear_header_and_sidebar(q: Q): + """Remove header and sidebar + + Header and sidebar should only be visible when user is logged in.""" + q.page["header"].items = [] + q.page["header"].secondary_items = [] + q.page["sidebar"].items = [] + + +@on() +async def login_page(q: Q, err_msg: str = ""): + """Login page. + + If the user is not logged in, any path of the app should lead here. + """ + clear_cards(q) + items = [ + ui.textbox('username', 'User'), + ui.textbox('password', 'Password', password=True), + ui.checkbox('stay_logged_in', label="Remember me", tooltip="Login does not expire."), + ui.button('login', "Login", primary=True) + ] + if err_msg: + items.append(ui.text_m(err_msg)) + add_card(q, 'login', ui.form_card(box='centered', items=items)) + + +async def login(q: Q, home_page: Callable[[Q], Awaitable[Any]]): + """Process login. + + Return to login page if credentials are invalid. Otherwise, route to home page. + """ + print("processing login") + if q.args.username == "" or q.args.password == "": + await login_page(q, "Missing username or password") + else: + secret = get_secret(q.args.username, q.args.password, q.args.stay_logged_in) + if secret is not None: + q.user.secret = secret + q.user.username = q.args.username + make_header_and_sidebar(q) + print("awaiting home page") + await home_page(q) + else: + await login_page(q, "Wrong credentials") + + +async def logout(q: Q): + """Remove the secret. Remove header and sidebar""" + q.user.secret = None + clear_header_and_sidebar(q) + q.page['meta'] = ui.meta_card(box='', script=ui.inline_script( + 'history.replaceState(history.state, "Logout", "/");' + )) # Manipulate address bar to show base address + await login_page(q)