diff --git a/docs/chapter-13.rst b/docs/chapter-13.rst index a6403aade..7313c0744 100644 --- a/docs/chapter-13.rst +++ b/docs/chapter-13.rst @@ -812,6 +812,378 @@ You will also have to register your OAuth2 redirect URI in your created applicat Discord username as the first name and discriminator as the last name. +Passkey (WebAuthn) +^^^^^^^^^^^^^^^^^^ + +To enable passkey login (Touch ID, Face ID, Windows Hello, hardware security keys, and any other authenticator supported by the browser), configure the following: + +.. note:: + The site must be served over HTTPS with a certificate the browser trusts. WebAuthn will not run over plain HTTP (except on ``localhost`` during development). + +Once is configured go to: https://yoursite/yourApp/my_keys to register your keys. + +Install webauth dependencies + +.. code:: python + pip install webauthn + + +settings.py +----------- + +.. code:: python + + USE_WEBAUTHN = True + WEBAUTHN_RP_NAME = "WebAuthn Login" + WEBAUTHN_RP_ID = "yourdomain.com" # may be an internal domain + WEBAUTHN_ORIGIN = "https://yourdomain.com" + + +common.py +--------- + +.. code:: python + + webauthn = None + if settings.USE_WEBAUTHN: + # Import the plugin only when the feature is enabled + from py4web.utils.auth_plugins.webauthn_plugin import WebAuthnPlugin + + # Create the global plugin instance + webauthn = WebAuthnPlugin( + auth, # the auth object + session, # the session object + rp_name=settings.WEBAUTHN_RP_NAME, + rp_id=settings.WEBAUTHN_RP_ID, + origin=settings.WEBAUTHN_ORIGIN, + ) + + +controller.py +------------- + +.. code:: python + + # --- WebAuthn section --- + + @action("auth2/webauthn//", method=["GET", "POST"]) + @action.uses(session, db) # No auth required here; the plugin handles it internally + def webauthn_router(path=None): + """ + Router for every WebAuthn plugin action. + The request is delegated to the plugin instance. + """ + # If the plugin is not enabled in settings, return 404 + if not webauthn: + raise HTTP(404, "WebAuthn is not enabled in this application.") + + try: + # The plugin already holds a reference to `auth`, so it knows + # whether a user is currently logged in. + return webauthn.handle_request(path) + except HTTP as e: + # Surface HTTP errors raised by the plugin as JSON + return {"error": str(e.body)} + + # --- Application pages that use the plugin --- + + @action("auth2/login") + @action.uses("webauthn_login.html", session, db, auth, url_signer) + def login(): + my_keys_url = URL('auth2/webauthn', signer=url_signer) + # Flag passed to the template so it knows whether to show the WebAuthn UI + return dict(use_webauthn=bool(webauthn), my_keys_url=my_keys_url) + + @action("my_keys") + @action.uses("my_keys.html", session, db, auth.user) + def profile(): + # Same flag is forwarded to the profile page + return dict(use_webauthn=bool(webauthn)) + + +Templates +--------- + +Two templates are needed. Add them under the ``templates`` folder. + +templates/webauthn_login.html +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: html + + [[extend 'layout.html']] + + [[if use_webauthn:]] +
+

Or sign in with your security key

+ +
+ +
+ +
+
+ +
+
+ +
+
+ +

+ + + [[pass]] + + +templates/my_keys.html +~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: html + + [[extend 'layout.html']] + +
+

My Profile

+

Welcome, [[=globals().get('user',{}).get('email')]]

+ +
+

Passkeys — hardware keys

+ +
Loading...
+
+
+ + + + + +templates/auth.html +~~~~~~~~~~~~~~~~~~~ + +Add a button that redirects the user to the passkey login page: + +.. code:: html + + [[extend 'layout.html']] + [[form.structure.append(A("Log in with passkey", _class="button is-light", _href=URL("auth2/login"))) ]] + + [[=form]] + + Auth API Plugins ~~~~~~~~~~~~~~~~ diff --git a/py4web/utils/auth_plugins/webauthn_plugin.py b/py4web/utils/auth_plugins/webauthn_plugin.py new file mode 100644 index 000000000..ba827e3e3 --- /dev/null +++ b/py4web/utils/auth_plugins/webauthn_plugin.py @@ -0,0 +1,178 @@ +# webauthn_plugin.py (VERSIÓN FINAL CON RENDERIZADO Y GUARDADO ROBUSTO) + +import json +import logging +import base64 + +from yatl.helpers import A, DIV, H4, P, SPAN, UL, LI, BUTTON +from py4web.core import Field, request, response, HTTP, URL +from pydal.validators import IS_EMAIL +# i18n settings + +from webauthn import ( + generate_registration_options, + verify_registration_response, + generate_authentication_options, + verify_authentication_response, + options_to_json, + base64url_to_bytes, +) +from webauthn.helpers.structs import ( + AuthenticatorSelectionCriteria, + AuthenticatorAttachment, + PublicKeyCredentialDescriptor, +) + +log = logging.getLogger(__name__) + + +# --- Helpers functions --- +def contains_dangerous_chars(text, dangerous_chars): + """ + Checks if any character from a list of dangerous characters is present in a string. + + Args: + text: The string to check. + dangerous_chars: A list of characters considered dangerous. + + Returns: + True if any dangerous character is found in the string, False otherwise. + """ + for char in dangerous_chars: + if char in text: + return True + return False + +def bytes_to_urlsafe_b64(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b'=').decode('utf-8') + +def urlsafe_b64_to_bytes(data: str) -> bytes: + padding = b'=' * (4 - (len(data) % 4)) + return base64.urlsafe_b64decode(data.encode('utf-8') + padding) + + +class WebAuthnPlugin: + def __init__(self, auth, session, rp_name: str, rp_id: str, origin: str): + self.auth = auth; self.db = auth.db; self.session = session; self.rp_name = rp_name + self.rp_id = rp_id; self.origin = origin; self.define_tables() + + def define_tables(self): + self.db.define_table("webauthn_credentials", Field("user_id", "reference auth_user", ondelete="CASCADE"), Field("name", "string"), Field("credential_id", "string", unique=True), Field("public_key", "string"), Field("sign_count", "integer", default=0)) + self.db.commit() + + def handle_request(self, path: str): + if path == "manage": return self._manage() + elif path == "register_begin": return self._register_begin() + elif path == "register_complete": return self._register_complete() + elif path == "login_begin": return self._login_begin() + elif path == "login_complete": return self._login_complete() + elif path == "delete": return self._delete_credential() + else: raise HTTP(404) + + def _manage(self): + #print(self.auth.current_user) + if not self.auth.current_user: raise HTTP(401) + user_id = self.auth.current_user["id"] + creds = self.db(self.db.webauthn_credentials.user_id == user_id).select() + #print(creds) + cred_list_items = [] + for cred in creds: + #log.error(cred) + HTML_ELEMENT = DIV( + H4(cred.name or "Key without name", _class="is-size-5"), + P(f"ID: {cred.credential_id[:8]}...", _class="has-text-grey"), + BUTTON("Delete", _class="button is-danger is-small", _onclick=f"deleteCredential('{cred.id}')"), + _class="box" + ) + + cred_list_items.append(HTML_ELEMENT) + cred_list_items = UL(*cred_list_items, _class="webauthn-credentials-list") + + return DIV(H4("Register a new security key or passkeys"), + P("Name for the new key/passkeys"), + DIV(A("Register new key", _id="btn-register", _class="button is-primary"), _class="my-4"), + H4("Registered Keys"), + DIV(cred_list_items) if cred_list_items else P("There are no registered keys."), P(_id="status", _class="has-text-info mt-4")) + + def _delete_credential(self): + if not self.auth.current_user: raise HTTP(401) + cred_id = request.json.get("id") + if not cred_id: raise HTTP(400) + num_deleted = self.db((self.db.webauthn_credentials.id == cred_id) & (self.db.webauthn_credentials.user_id == self.auth.current_user["id"])).delete() + if num_deleted == 0: raise HTTP(404) + return {"status": "ok"} + + def _register_begin(self): + if not self.auth.current_user: raise HTTP(401) + user = self.auth.current_user + creds = self.db(self.db.webauthn_credentials.user_id == user["id"]).select() + exclude_credentials = [PublicKeyCredentialDescriptor(id=bytes.fromhex(c.credential_id)) for c in creds] + options = generate_registration_options(rp_id=self.rp_id, rp_name=self.rp_name, user_id=str(user["id"]).encode("utf-8"), user_name=user["email"], exclude_credentials=exclude_credentials) + self.session["webauthn_challenge"] = bytes_to_urlsafe_b64(options.challenge) + response.headers["Content-Type"] = "application/json" + return options_to_json(options) + + def _register_complete(self): + if not self.auth.current_user: raise HTTP(401) + credential = request.json + challenge_b64 = self.session.get("webauthn_challenge") + if not challenge_b64: raise HTTP(400) + challenge = urlsafe_b64_to_bytes(challenge_b64) + + key_name = credential.pop("name", "No name key") + # HIGHLIGHT 2: Si el nombre viene vacío o solo con espacios, usamos un valor por defecto. + if not key_name or not key_name.strip(): + key_name = "No name key" + + try: + verification = verify_registration_response(credential=credential, expected_challenge=challenge, expected_origin=self.origin, expected_rp_id=self.rp_id) + self.db.webauthn_credentials.insert(user_id=self.auth.current_user["id"], name=key_name, credential_id=verification.credential_id.hex(), public_key=verification.credential_public_key.hex(), sign_count=verification.sign_count) + return {"verified": True} + except Exception as e: + log.error(f"Error en registro WebAuthn: {e}"); raise HTTP(400, f"Error en la verificación: {e}") + + + def _login_begin(self): + email = request.json.get("email") + if not email: raise HTTP(400, "Please enter your email.") + #make a filter to check if the email is valid, find chars like '",: and others that are not allowed in an email + # HIGHLIGHT 1: Validación del email usando IS_EMAIL de pydal + #print(IS_EMAIL()(email)) + if IS_EMAIL()(email)[1] is not None: raise HTTP(400, "Invalid email format.") + if contains_dangerous_chars(email, ['"', "'", ':', ';', '<', '>', '\\', '/', '|', '?', '*']): + raise HTTP(400, "Email contains invalid characters.") + user = self.db(self.db.auth_user.email == email).select().first() + if user is None: raise HTTP(200, "Invalid authentication method for user.") + #if not user: + # options = generate_authentication_options(rp_id=self.rp_id) + # response.headers["Content-Type"] = "application/json" + # return options_to_json(options) + creds = self.db(self.db.webauthn_credentials.user_id == user.id).select() + if not creds: raise HTTP(404, "Invaid authentication method") + allow_credentials = [PublicKeyCredentialDescriptor(id=bytes.fromhex(c.credential_id)) for c in creds] + options = generate_authentication_options(rp_id=self.rp_id, allow_credentials=allow_credentials) + self.session["webauthn_challenge"] = bytes_to_urlsafe_b64(options.challenge) + self.session["webauthn_user_id"] = user.id + response.headers["Content-Type"] = "application/json" + return options_to_json(options) + + def _login_complete(self): + credential = request.json + challenge_b64 = self.session.get("webauthn_challenge") + user_id = self.session.get("webauthn_user_id") + if not all([credential, challenge_b64, user_id]): raise HTTP(400) + challenge = urlsafe_b64_to_bytes(challenge_b64) + cred_id_from_client_bytes = base64url_to_bytes(credential["id"]) + cred_in_db = self.db((self.db.webauthn_credentials.user_id == user_id) & (self.db.webauthn_credentials.credential_id == cred_id_from_client_bytes.hex())).select().first() + if not cred_in_db: raise HTTP(404) + try: + public_key_bytes = bytes.fromhex(cred_in_db.public_key) + verification = verify_authentication_response(credential=credential, expected_challenge=challenge, expected_origin=self.origin, expected_rp_id=self.rp_id, credential_public_key=public_key_bytes, credential_current_sign_count=cred_in_db.sign_count) + cred_in_db.update_record(sign_count=verification.new_sign_count) + user = self.db.auth_user(user_id) + self.session["user"] = user.as_dict() + del self.session["webauthn_challenge"] + del self.session["webauthn_user_id"] + return {"verified": True, "redirect_url": URL("index")} + except Exception as e: + log.error(f"Error en login WebAuthn: {e}"); raise HTTP(403, f"Invalid authentication response") \ No newline at end of file diff --git a/py4web/utils/mailer.py b/py4web/utils/mailer.py index e313e419c..4e37d11eb 100644 --- a/py4web/utils/mailer.py +++ b/py4web/utils/mailer.py @@ -18,6 +18,9 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.base import MIMEBase +from email.charset import Charset, QP as charset_QP, add_charset +from email.header import Header +from email import encoders as Encoders try: from google.appengine.api import mail as google_mail