Browse Source

Merge branch 'webauthn' into 'master'

Draft: #646 Implement Webauthn support

See merge request tildes/tildes!140
merge-requests/140/merge
Joshua Hayes-Sheen 4 weeks ago
parent
commit
c85cfa6448
  1. 2
      tildes/requirements-dev.txt
  2. 1
      tildes/requirements.txt
  3. 28
      tildes/tildes/models/user/user.py
  4. 26
      tildes/tildes/templates/intercooler/login_webauthn.jinja2
  5. 5
      tildes/tildes/templates/intercooler/webauthn_disabled.jinja2
  6. 4
      tildes/tildes/templates/intercooler/webauthn_enabled.jinja2
  7. 59
      tildes/tildes/templates/settings_webauthn.jinja2
  8. 45
      tildes/tildes/views/api/web/user.py
  9. 47
      tildes/tildes/views/login.py
  10. 23
      tildes/tildes/views/settings.py

2
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

1
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

28
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."""

26
tildes/tildes/templates/intercooler/login_webauthn.jinja2

@ -0,0 +1,26 @@
{# Copyright (c) 2018 Tildes contributors <code@tildes.net> #}
{# SPDX-License-Identifier: AGPL-3.0-or-later #}
<p>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.</p>
<form id="webauthn-login-form" class="form-narrow" method="post" autocomplete="off" action="/login_webauthn" data-ic-post-to="/login_webauthn" onload="assertCredentials()">
<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}" />
<input type="hidden" name="from_url" value="{{ from_url }}" />
{% if keep %}
<input type="hidden" name="keep" value="on" />
{% endif %}
<input class="form-input" id="assertion" name="assertion" type="hidden" />
<script>
const auth_options = JSON.parse(atob('{{ auth_options }}'));
function assertCredentials() {
navigator.credentials.get(auth_options).then((credential) => {
document.getElementById("#webauthn-login-form").submit()
document.getElementById("#assertion").text(btoa(credential.response));
});
}
</script>
<div class="form-buttons">
<button class="btn btn-primary" type="submit">Continue</button>
</div>
</form>

5
tildes/tildes/templates/intercooler/webauthn_disabled.jinja2

@ -0,0 +1,5 @@
{# Copyright (c) 2018 Tildes contributors <code@tildes.net> #}
{# SPDX-License-Identifier: AGPL-3.0-or-later #}
<p>Webauthn authentication has been disabled. You will no longer need a code when logging in.</p>

4
tildes/tildes/templates/intercooler/webauthn_enabled.jinja2

@ -0,0 +1,4 @@
{# Copyright (c) 2018 Tildes contributors <code@tildes.net> #}
{# SPDX-License-Identifier: AGPL-3.0-or-later #}
<p>Congratulations! Webauthn authentication has been enabled.</p>

59
tildes/tildes/templates/settings_webauthn.jinja2

@ -0,0 +1,59 @@
{# Copyright (c) 2018 Tildes contributors <code@tildes.net> #}
{# 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 %}
<div class="divider"></div>
<p>To disable webauthn authentication, click the button below</p>
<form
name="disable-webauthn"
autocomplete="off"
data-ic-post-to="{{ request.route_url('ic_user', username=request.user.username) }}"
data-ic-target="closest main"
>
<div class="form-buttons">
<button class="btn btn-error" type="submit">Disable webauthn authentication</button>
</div>
</form>
{% else %}
<p>To get started, you'll need an authenticator such as a Yubikey, or you can use your specific computer as an authenticator</p>
<p>Next, click below to enroll your authenticator</p>
<script>
// It would probably be a lot better to load this on demand when trying to enroll an authenticator
const credential_options = JSON.parse(atob('{{ webauthn_challenge }}'))
function enrollAuthenticator() {
navigator.credentials.create(credential_options).then((credentials) => {
document.getElementById("#webauthn-assertion").text(btoa(JSON.stringify(credentials.response)));
return true;
});
}
</script>
<div class="divider"></div>
<form
name="enable-webauthn"
autocomplete="off"
data-ic-post-to="{{ request.route_url('ic_user', username=request.user.username) }}"
data-ic-target="closest main"
>
<input class="form-input" id="webauthn-assertion" name="webauthn-assertion" type="hidden" />
<div class="form-buttons">
<button class="btn btn-primary" type="submit" onclick="enrollAuthenticator()">Enable Webauthn</button>
</div>
</form>
{% endif %}
{% endblock %}

45
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"]
@ -101,6 +101,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",
@ -151,6 +173,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",

47
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."""

23
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"]
@ -97,6 +97,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."""

Loading…
Cancel
Save