-
Notifications
You must be signed in to change notification settings - Fork 54
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Wave app with JWT authentication (#118)
- Loading branch information
Showing
11 changed files
with
585 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
h2o-wave==0.25.2 | ||
mongoengine | ||
python-jose[cryptography] | ||
passlib[bcrypt] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
Oops, something went wrong.