Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Wave app with JWT authentication #118

Merged
merged 4 commits into from
Jul 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.


## FAQs
Expand Down
48 changes: 48 additions & 0 deletions jwt-auth/README.md
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.
240 changes: 240 additions & 0 deletions jwt-auth/app.py
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()
Binary file added jwt-auth/img/after_login.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added jwt-auth/img/before_login.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 36 additions & 0 deletions jwt-auth/mongodb_layer.py
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
4 changes: 4 additions & 0 deletions jwt-auth/requirements.txt
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]
35 changes: 35 additions & 0 deletions jwt-auth/user_auth.py
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))
19 changes: 19 additions & 0 deletions jwt-auth/user_register.py
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)
Loading