diff --git a/examples/magickey_auth/main.py b/examples/magickey_auth/main.py new file mode 100644 index 0000000..86798c3 --- /dev/null +++ b/examples/magickey_auth/main.py @@ -0,0 +1,59 @@ +from fasthtml.common import * +from fasthtml.magickey import MagicKey +from plash_cli.auth import send_magiclink + +db = database('data/data.db') +class User: id:int; email:str +class Passkey: id:str; user_id:int; public_key:bytes; sign_count:int +users = db.create(User) +passkeys = db.create(Passkey) + +class Auth(MagicKey): + def get_user_id(self, email): + res = users(where="email = ?", where_args=[email]) + if res: return res[0].id + return users.insert(User(email=email)).id + def has_passkey(self, email): + return bool(passkeys(where="user_id = ?", where_args=[self.get_user_id(email)])) + def get_passkey(self, cred_id): + try: r = passkeys[cred_id] + except NotFoundError: return None + return dict(email=users[r.user_id].email, public_key=r.public_key, sign_count=r.sign_count) + def save_passkey(self, cred_id, email, public_key, sign_count): + uid = self.get_user_id(email) + passkeys.insert(Passkey(id=cred_id, user_id=uid, public_key=public_key, sign_count=sign_count)) + def update_passkey(self, cred_id, sign_count): + passkeys.update(Passkey(id=cred_id, sign_count=sign_count)) + +def send_email(email, url): + res = send_magiclink(email,url) + if res.status_code == 200: return P(f'Your magic login link has beent sent to: {email}.') + else: return P('Something went wrong, try again later.') + +app, rt = fast_app() +mk = Auth(app, send_email=send_email) + +@rt('/') +def home(auth): + u = users[auth] + return P(f'Hello {u.email}!'), A('Log out', href='/logout') + +@rt('/login') +def login(error: str=None): + errmsg = P(error.replace('_', ' ').title(), style='color:red') if error else '' + return Titled('Sign In', errmsg, + Button('Sign in with Passkey', hx_post='/request_passkey_auth', target_id='scripts'), + Hr(), + Form(method='post', action='/send_magic_link')( + Input(name='email', type='email', placeholder='you@example.com'), + Button('Send Magic Link')), + Div(id='scripts')) + +@rt('/setup_passkey') +def setup_passkey(): return Titled('Set Up Passkey', + P('Set up a passkey for faster logins next time?'), + Button('Register Passkey', hx_post='/request_passkey_reg', target_id='scripts'), + Form(Button('Skip'), method='post', action='/skip_passkey_reg'), + Div(id='scripts')) + +serve() diff --git a/examples/magickey_auth/requirements.txt b/examples/magickey_auth/requirements.txt new file mode 100644 index 0000000..25c8d0b --- /dev/null +++ b/examples/magickey_auth/requirements.txt @@ -0,0 +1,4 @@ +plash_cli @ git+https://github.com/AnswerDotAI/plash_cli@magickey +python-fasthtml @ git+https://github.com/AnswerDotAI/fasthtml@magickey +webauthn +numpy \ No newline at end of file diff --git a/nbs/01_auth.ipynb b/nbs/01_auth.ipynb index 9eb2c3d..4bc0607 100644 --- a/nbs/01_auth.ipynb +++ b/nbs/01_auth.ipynb @@ -10,12 +10,20 @@ "> Client side logic to add Plash Auth to your app" ] }, + { + "cell_type": "markdown", + "id": "4416a9b8", + "metadata": {}, + "source": [ + "## Google Auth" + ] + }, { "cell_type": "markdown", "id": "188fb79d", "metadata": {}, "source": [ - "This page describes how Plash Auth is implemented client side. \n", + "This section describes how Plash Auth implements Google Auth client side. \n", "\n", "Please see the [how to](how_to/auth.html) for instructions on how to use it." ] @@ -25,7 +33,7 @@ "id": "59ce8def", "metadata": {}, "source": [ - "## Setup -" + "### Setup -" ] }, { @@ -73,7 +81,7 @@ "source": [ "#| export\n", "def _signin_url(email_re: str=None, hd_re: str=None):\n", - " res = httpx.post(os.environ['PLASH_AUTH_URL'], json=dict(email_re=email_re, hd_re=hd_re), \n", + " res = httpx.post(os.environ['PLASH_AUTH_URL']+'/request_signin', json=dict(email_re=email_re, hd_re=hd_re), \n", " auth=(os.environ['PLASH_APP_ID'], os.environ['PLASH_APP_SECRET']), \n", " headers={'X-PLASH-AUTH-VERSION': __version__}).raise_for_status().json()\n", " if \"warning\" in res: warn(res.pop('warning'))\n", @@ -116,8 +124,8 @@ "source": [ "#| export\n", "def mk_signin_url(session: dict, # Session dictionary\n", - " email_re: str=None, # Regex filter for allowed email addresses\n", - " hd_re: str=None): # Regex filter for allowed Google hosted domains\n", + " email_re: str=None, # Regex filter for allowed email addresses\n", + " hd_re: str=None): # Regex filter for allowed Google hosted domains\n", " \"Generate a Google Sign-In URL for Plash authentication.\"\n", " if not _in_prod: return f\"{signin_completed_rt}?signin_reply=mock-sign-in-reply\"\n", " res = _signin_url(email_re, hd_re)\n", @@ -216,6 +224,40 @@ "When testing locally this will always return the mock Google ID `'424242424242424242424'`." ] }, + { + "cell_type": "markdown", + "id": "ba560a3a", + "metadata": {}, + "source": [ + "## Magic Link" + ] + }, + { + "cell_type": "markdown", + "id": "1c7b6cd6", + "metadata": {}, + "source": [ + "Pla.sh provides a service where you can send magic links for sign-up or login to your users for free. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ad5693c9", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def send_magiclink(email: str, # Email address to send magic link to\n", + " url: str): # Magic link URL (must match app's domain)\n", + " \"Send a magic link email to the given address via Plash Auth.\"\n", + " return httpx.post(os.environ['PLASH_AUTH_URL']+'/send_magiclink',\n", + " json=dict(email=email, url=url),\n", + " auth=(os.environ['PLASH_APP_ID'], os.environ['PLASH_APP_SECRET']),\n", + " headers={'X-PLASH-AUTH-VERSION': __version__}\n", + " )" + ] + }, { "cell_type": "markdown", "id": "72eaabaa", @@ -238,11 +280,8 @@ } ], "metadata": { - "kernelspec": { - "display_name": "python3", - "language": "python", - "name": "python3" - } + "solveit_dialog_mode": "learning", + "solveit_ver": 2 }, "nbformat": 4, "nbformat_minor": 5 diff --git a/plash_cli/_modidx.py b/plash_cli/_modidx.py index 726c355..41a3f2f 100644 --- a/plash_cli/_modidx.py +++ b/plash_cli/_modidx.py @@ -9,7 +9,8 @@ 'plash_cli.auth._parse_jwt': ('auth.html#_parse_jwt', 'plash_cli/auth.py'), 'plash_cli.auth._signin_url': ('auth.html#_signin_url', 'plash_cli/auth.py'), 'plash_cli.auth.goog_id_from_signin_reply': ('auth.html#goog_id_from_signin_reply', 'plash_cli/auth.py'), - 'plash_cli.auth.mk_signin_url': ('auth.html#mk_signin_url', 'plash_cli/auth.py')}, + 'plash_cli.auth.mk_signin_url': ('auth.html#mk_signin_url', 'plash_cli/auth.py'), + 'plash_cli.auth.send_magiclink': ('auth.html#send_magiclink', 'plash_cli/auth.py')}, 'plash_cli.cli': { 'plash_cli.cli.Path._is_dir_empty': ('cli.html#path._is_dir_empty', 'plash_cli/cli.py'), 'plash_cli.cli.PlashError': ('cli.html#plasherror', 'plash_cli/cli.py'), 'plash_cli.cli._app_list': ('cli.html#_app_list', 'plash_cli/cli.py'), diff --git a/plash_cli/auth.py b/plash_cli/auth.py index 511fbf9..a54782d 100644 --- a/plash_cli/auth.py +++ b/plash_cli/auth.py @@ -3,7 +3,7 @@ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/01_auth.ipynb. # %% auto #0 -__all__ = ['signin_completed_rt', 'mk_signin_url', 'PlashAuthError', 'goog_id_from_signin_reply'] +__all__ = ['signin_completed_rt', 'mk_signin_url', 'PlashAuthError', 'goog_id_from_signin_reply', 'send_magiclink'] # %% ../nbs/01_auth.ipynb #c6aa552a import httpx,os,jwt @@ -17,7 +17,7 @@ # %% ../nbs/01_auth.ipynb #e6590075 def _signin_url(email_re: str=None, hd_re: str=None): - res = httpx.post(os.environ['PLASH_AUTH_URL'], json=dict(email_re=email_re, hd_re=hd_re), + res = httpx.post(os.environ['PLASH_AUTH_URL']+'/request_signin', json=dict(email_re=email_re, hd_re=hd_re), auth=(os.environ['PLASH_APP_ID'], os.environ['PLASH_APP_SECRET']), headers={'X-PLASH-AUTH-VERSION': __version__}).raise_for_status().json() if "warning" in res: warn(res.pop('warning')) @@ -28,8 +28,8 @@ def _signin_url(email_re: str=None, hd_re: str=None): # %% ../nbs/01_auth.ipynb #74a0a24d def mk_signin_url(session: dict, # Session dictionary - email_re: str=None, # Regex filter for allowed email addresses - hd_re: str=None): # Regex filter for allowed Google hosted domains + email_re: str=None, # Regex filter for allowed email addresses + hd_re: str=None): # Regex filter for allowed Google hosted domains "Generate a Google Sign-In URL for Plash authentication." if not _in_prod: return f"{signin_completed_rt}?signin_reply=mock-sign-in-reply" res = _signin_url(email_re, hd_re) @@ -58,3 +58,13 @@ def goog_id_from_signin_reply(session: dict, # Session dictionary containing 're if session.get('req_id') != parsed['req_id']: raise PlashAuthError("Request originated from a different browser than the one receiving the reply") if parsed['err']: raise PlashAuthError(f"Authentication failed: {parsed['err']}") return parsed['sub'] + +# %% ../nbs/01_auth.ipynb #ad5693c9 +def send_magiclink(email: str, # Email address to send magic link to + url: str): # Magic link URL (must match app's domain) + "Send a magic link email to the given address via Plash Auth." + return httpx.post(os.environ['PLASH_AUTH_URL']+'/send_magiclink', + json=dict(email=email, url=url), + auth=(os.environ['PLASH_APP_ID'], os.environ['PLASH_APP_SECRET']), + headers={'X-PLASH-AUTH-VERSION': __version__} + )