From 4f3ee2ce864dc981d327d201d59ee24e89c4283e Mon Sep 17 00:00:00 2001 From: Josh Hayes-Sheen Date: Sat, 10 Jun 2023 17:47:59 -0400 Subject: [PATCH] #646 Implement Webauthn support --- tildes/requirements-dev.txt | 2 + tildes/requirements.txt | 1 + tildes/tildes/models/user/user.py | 28 ++++++++- .../intercooler/login_webauthn.jinja2 | 26 ++++++++ .../intercooler/webauthn_disabled.jinja2 | 5 ++ .../intercooler/webauthn_enabled.jinja2 | 4 ++ .../tildes/templates/settings_webauthn.jinja2 | 59 +++++++++++++++++++ tildes/tildes/views/api/web/user.py | 45 +++++++++++++- tildes/tildes/views/login.py | 47 +++++++++++++++ tildes/tildes/views/settings.py | 23 +++++++- 10 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 tildes/tildes/templates/intercooler/login_webauthn.jinja2 create mode 100644 tildes/tildes/templates/intercooler/webauthn_disabled.jinja2 create mode 100644 tildes/tildes/templates/intercooler/webauthn_enabled.jinja2 create mode 100644 tildes/tildes/templates/settings_webauthn.jinja2 diff --git a/tildes/requirements-dev.txt b/tildes/requirements-dev.txt index b29fba1..a0da993 100644 --- a/tildes/requirements-dev.txt +++ b/tildes/requirements-dev.txt @@ -127,3 +127,5 @@ zope.sqlalchemy==1.5 # The following packages are considered to be unsafe in a requirements file: # pip # setuptools + +setuptools~=57.4.0 \ No newline at end of file diff --git a/tildes/requirements.txt b/tildes/requirements.txt index d570f0f..36e9298 100644 --- a/tildes/requirements.txt +++ b/tildes/requirements.txt @@ -72,6 +72,7 @@ venusian==3.0.0 wcwidth==0.2.5 webargs==8.0.0 webassets==2.0 +webauthn==1.8.1 webencodings==0.5.1 webob==1.8.7 wheel==0.36.2 diff --git a/tildes/tildes/models/user/user.py b/tildes/tildes/models/user/user.py index 2229824..1fc408d 100644 --- a/tildes/tildes/models/user/user.py +++ b/tildes/tildes/models/user/user.py @@ -30,6 +30,10 @@ from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import deferred from sqlalchemy.sql.expression import text +from webauthn import verify_authentication_response, base64url_to_bytes +from webauthn.helpers.exceptions import InvalidAuthenticationResponse +from webauthn.helpers.structs import AuthenticationCredential + from tildes.enums import ( CommentLabelOption, CommentTreeSortOption, @@ -48,7 +52,6 @@ from tildes.schemas.user import ( ) from tildes.typing import AclType - class User(DatabaseModel): """Model for a user's account on the site. @@ -85,6 +88,8 @@ class User(DatabaseModel): two_factor_enabled: bool = Column(Boolean, nullable=False, server_default="false") two_factor_secret: Optional[str] = deferred(Column(Text)) two_factor_backup_codes: list[str] = deferred(Column(ARRAY(Text))) + webauthn_enabled: bool = Column(Boolean, nullable=False, server_default="false") + webauthn_public_key: Optional[str] = deferred(Column(Text)) created_time: datetime = Column( TIMESTAMP(timezone=True), nullable=False, @@ -296,6 +301,27 @@ class User(DatabaseModel): return False + def is_valid_assertion(self, challenge: str, assertion: str) -> bool: + if not self.webauthn_public_key: + raise ValueError("User does not have WebAuthn enabled") + + try: + verification = verify_authentication_response( + credential=AuthenticationCredential.parse_raw(assertion), + expected_challenge=base64url_to_bytes(challenge), + expected_rp_id="tildes", + expected_origin=["https://tildes.net/login"], + credential_public_key=base64url_to_bytes(self.webauthn_public_key), + credential_current_sign_count=0, + require_user_verification=True + ) + is_validated = True + + except InvalidAuthenticationResponse: + is_validated = False + + return is_validated + @property def email_address(self) -> NoReturn: """Return an error since reading the email address isn't possible.""" diff --git a/tildes/tildes/templates/intercooler/login_webauthn.jinja2 b/tildes/tildes/templates/intercooler/login_webauthn.jinja2 new file mode 100644 index 0000000..6f3daa7 --- /dev/null +++ b/tildes/tildes/templates/intercooler/login_webauthn.jinja2 @@ -0,0 +1,26 @@ +{# Copyright (c) 2018 Tildes contributors #} +{# SPDX-License-Identifier: AGPL-3.0-or-later #} + +

WebAuthn authentication is enabled on this account. Please use your authenticator when prompted. If you do not have access to your authenticator device, enter a backup code.

+ +
+ + + {% if keep %} + + {% endif %} + + + +
+ +
+
diff --git a/tildes/tildes/templates/intercooler/webauthn_disabled.jinja2 b/tildes/tildes/templates/intercooler/webauthn_disabled.jinja2 new file mode 100644 index 0000000..9a4d831 --- /dev/null +++ b/tildes/tildes/templates/intercooler/webauthn_disabled.jinja2 @@ -0,0 +1,5 @@ +{# Copyright (c) 2018 Tildes contributors #} +{# SPDX-License-Identifier: AGPL-3.0-or-later #} + +

Webauthn authentication has been disabled. You will no longer need a code when logging in.

+ diff --git a/tildes/tildes/templates/intercooler/webauthn_enabled.jinja2 b/tildes/tildes/templates/intercooler/webauthn_enabled.jinja2 new file mode 100644 index 0000000..861383c --- /dev/null +++ b/tildes/tildes/templates/intercooler/webauthn_enabled.jinja2 @@ -0,0 +1,4 @@ +{# Copyright (c) 2018 Tildes contributors #} +{# SPDX-License-Identifier: AGPL-3.0-or-later #} + +

Congratulations! Webauthn authentication has been enabled.

diff --git a/tildes/tildes/templates/settings_webauthn.jinja2 b/tildes/tildes/templates/settings_webauthn.jinja2 new file mode 100644 index 0000000..e47789c --- /dev/null +++ b/tildes/tildes/templates/settings_webauthn.jinja2 @@ -0,0 +1,59 @@ +{# Copyright (c) 2018 Tildes contributors #} +{# SPDX-License-Identifier: AGPL-3.0-or-later #} + +{% extends 'base_settings.jinja2' %} + +{% block title %}Set up webauthn authentication{% endblock %} + +{% block main_heading %}Set up webauthn authentication{% endblock %} + +{% block settings %} +{% if request.user.webauthn_enabled %} + +
+ +

To disable webauthn authentication, click the button below

+ +
+ +
+ +
+
+{% else %} +

To get started, you'll need an authenticator such as a Yubikey, or you can use your specific computer as an authenticator

+ +

Next, click below to enroll your authenticator

+ + +
+ +
+ + +
+ +
+
+{% endif %} +{% endblock %} diff --git a/tildes/tildes/views/api/web/user.py b/tildes/tildes/views/api/web/user.py index ef80973..08029de 100644 --- a/tildes/tildes/views/api/web/user.py +++ b/tildes/tildes/views/api/web/user.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """Web API endpoints related to users.""" - +import base64 import random import string from typing import Optional @@ -28,7 +28,7 @@ from tildes.schemas.topic import TopicSchema from tildes.schemas.user import UserSchema from tildes.views import IC_NOOP from tildes.views.decorators import ic_view_config, use_kwargs - +from webauthn.helpers.structs import RegistrationCredential PASSWORD_FIELD = UserSchema(only=("password",)).fields["password"] @@ -95,6 +95,28 @@ def patch_change_email_address( return Response("Your email address has been updated") +@ic_view_config( + route_name="user", + request_method="POST", + request_param="ic-trigger-name=enable-webauthn", + renderer="webauthn_enabled.jinja2", + permission="change_settings", +) +@use_kwargs({"code": String()}, location="form") +def post_enable_webauthn(request: Request, assertion: str) -> dict: + user = request.context + + if not user.is_valid_assertion(request.session["assertion_challenge"], assertion): + raise HTTPUnprocessableEntity("Invalid assertion, please try again.") + + credentials = RegistrationCredential.parse_raw(base64.standard_b64decode(assertion)) + + request.user.webauthn_enabled = True + request.user.webauthn_public_key = base64.standard_b64encode(credentials.response.attestation_object) + + return {} + + @ic_view_config( route_name="user", request_method="POST", @@ -145,6 +167,25 @@ def post_disable_two_factor(request: Request, code: str) -> Response: return {} +@ic_view_config( + route_name="user", + request_method="POST", + request_param="ic-trigger-name=disable-webauthn", + renderer="webauthn_disabled.jinja2", + permission="change_settings", +) +@use_kwargs({"code": String()}, location="form") +def post_disable_webauthn(request: Request, assertion: str) -> Response: + """Disable two-factor authentication for the user.""" + if not request.user.is_valid_assertion(assertion, request.session["webauthn_challenge"]): + raise HTTPUnauthorized(body="Invalid Assertion") + + request.user.webauthn_enabled = False + request.user.webauthn_public_key = None + + return {} + + @ic_view_config( route_name="user", request_method="POST", diff --git a/tildes/tildes/views/login.py b/tildes/tildes/views/login.py index b6f519d..a051192 100644 --- a/tildes/tildes/views/login.py +++ b/tildes/tildes/views/login.py @@ -21,6 +21,7 @@ from tildes.models.log import Log from tildes.models.user import User from tildes.schemas.user import UserSchema from tildes.views.decorators import not_logged_in, rate_limit_view, use_kwargs +from webauthn import generate_authentication_options, options_to_json @view_config( @@ -137,6 +138,25 @@ def post_login( request=request, ) + if user.webauthn_enabled: + request.session["webauthn_username"] = username + + # Generate an authorization option set + auth_options = generate_authentication_options(rp_id="tilde") + + # Copy the challenge into the session, to ensure it can't be tampered with + request.session["webauthn_challenge"] = auth_options.challenge + + return render_to_response( + "tildes:templates/intercooler/login_webauthn.jinja2", + { + "keep": request.params.get("keep"), + "from_url": from_url, + "auth_options": options_to_json(auth_options), + }, + request=request, + ) + raise finish_login(request, user, from_url) @@ -167,6 +187,33 @@ def post_login_two_factor(request: Request, code: str, from_url: str) -> NoRetur raise finish_login(request, user, from_url) +@view_config( + route_name="login_webauthn", + request_method="POST", + permission=NO_PERMISSION_REQUIRED, +) +@not_logged_in +@rate_limit_view("login_webauthn") +@use_kwargs( + {"code": String(missing=""), "from_url": String(missing="")}, location="form" +) +def post_login_webauthn(request: Request, assertion: str, from_url: str) -> NoReturn: + """Process a log in request with WebAuthn""" + # Look up the user for the supplied username + user = ( + request.query(User) + .undefer_all_columns() + .filter(User.username == request.session["webauthn_username"]) + .one_or_none() + ) + + if not user.is_valid_assertion(request.session["webauthn_challenge"], assertion): + raise HTTPUnauthorized(body="Invalid assertion, please try again.") + + del request.session["two_factor_username"] + raise finish_login(request, user, from_url) + + @view_config(route_name="logout", request_method="POST") def post_logout(request: Request) -> HTTPFound: """Process a log out request.""" diff --git a/tildes/tildes/views/settings.py b/tildes/tildes/views/settings.py index d6c9b38..47e613a 100644 --- a/tildes/tildes/views/settings.py +++ b/tildes/tildes/views/settings.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """Views related to user settings.""" - +import base64 from datetime import timedelta from io import BytesIO import sys @@ -28,7 +28,7 @@ from tildes.schemas.user import ( UserSchema, ) from tildes.views.decorators import use_kwargs - +from webauthn import generate_registration_options, options_to_json PASSWORD_FIELD = UserSchema(only=("password",)).fields["password"] @@ -95,6 +95,25 @@ def get_settings_two_factor(request: Request) -> dict: } +@view_config(route_name="settings_webauthn", renderer="settings_webauthn.jinja2") +def get_settings_settings_webauthn(request: Request) -> dict: + """Generate the webauthn authentication setting page.""" + # Generate a registration challenge, if the user does not have webauthn enabled + if not request.user.webauthn_enabled: + registration_options = generate_registration_options( + rp_id="tilde", + rp_name="Tilde", + user_id=request.user.user_id, + user_name=request.user.username, + ) + # Copy the challenge into the session so the user can't tamper with it before signing it + request.session["assertion_challenge"] = registration_options.challenge + + return { + "webauthn_challenge": base64.standard_b64encode(options_to_json(registration_options)) + } + + @view_config(route_name="settings_filters", renderer="settings_filters.jinja2") def get_settings_filters(request: Request) -> dict: """Generate the filters settings page."""