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: # The following packages are considered to be unsafe in a requirements file:
# pip # pip
# setuptools # setuptools
setuptools~=57.4.0

1
tildes/requirements.txt

@ -72,6 +72,7 @@ venusian==3.0.0
wcwidth==0.2.5 wcwidth==0.2.5
webargs==8.0.0 webargs==8.0.0
webassets==2.0 webassets==2.0
webauthn==1.8.1
webencodings==0.5.1 webencodings==0.5.1
webob==1.8.7 webob==1.8.7
wheel==0.36.2 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.orm import deferred
from sqlalchemy.sql.expression import text 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 ( from tildes.enums import (
CommentLabelOption, CommentLabelOption,
CommentTreeSortOption, CommentTreeSortOption,
@ -48,7 +52,6 @@ from tildes.schemas.user import (
) )
from tildes.typing import AclType from tildes.typing import AclType
class User(DatabaseModel): class User(DatabaseModel):
"""Model for a user's account on the site. """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_enabled: bool = Column(Boolean, nullable=False, server_default="false")
two_factor_secret: Optional[str] = deferred(Column(Text)) two_factor_secret: Optional[str] = deferred(Column(Text))
two_factor_backup_codes: list[str] = deferred(Column(ARRAY(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( created_time: datetime = Column(
TIMESTAMP(timezone=True), TIMESTAMP(timezone=True),
nullable=False, nullable=False,
@ -296,6 +301,27 @@ class User(DatabaseModel):
return False 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 @property
def email_address(self) -> NoReturn: def email_address(self) -> NoReturn:
"""Return an error since reading the email address isn't possible.""" """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 # SPDX-License-Identifier: AGPL-3.0-or-later
"""Web API endpoints related to users.""" """Web API endpoints related to users."""
import base64
import random import random
import string import string
from typing import Optional from typing import Optional
@ -28,7 +28,7 @@ from tildes.schemas.topic import TopicSchema
from tildes.schemas.user import UserSchema from tildes.schemas.user import UserSchema
from tildes.views import IC_NOOP from tildes.views import IC_NOOP
from tildes.views.decorators import ic_view_config, use_kwargs from tildes.views.decorators import ic_view_config, use_kwargs
from webauthn.helpers.structs import RegistrationCredential
PASSWORD_FIELD = UserSchema(only=("password",)).fields["password"] PASSWORD_FIELD = UserSchema(only=("password",)).fields["password"]
@ -101,6 +101,28 @@ def patch_change_email_address(
return Response("Your email address has been updated") 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( @ic_view_config(
route_name="user", route_name="user",
request_method="POST", request_method="POST",
@ -151,6 +173,25 @@ def post_disable_two_factor(request: Request, code: str) -> Response:
return {} 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( @ic_view_config(
route_name="user", route_name="user",
request_method="POST", 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.models.user import User
from tildes.schemas.user import UserSchema from tildes.schemas.user import UserSchema
from tildes.views.decorators import not_logged_in, rate_limit_view, use_kwargs from tildes.views.decorators import not_logged_in, rate_limit_view, use_kwargs
from webauthn import generate_authentication_options, options_to_json
@view_config( @view_config(
@ -137,6 +138,25 @@ def post_login(
request=request, 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) 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) 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") @view_config(route_name="logout", request_method="POST")
def post_logout(request: Request) -> HTTPFound: def post_logout(request: Request) -> HTTPFound:
"""Process a log out request.""" """Process a log out request."""

23
tildes/tildes/views/settings.py

@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
"""Views related to user settings.""" """Views related to user settings."""
import base64
from datetime import timedelta from datetime import timedelta
from io import BytesIO from io import BytesIO
import sys import sys
@ -28,7 +28,7 @@ from tildes.schemas.user import (
UserSchema, UserSchema,
) )
from tildes.views.decorators import use_kwargs from tildes.views.decorators import use_kwargs
from webauthn import generate_registration_options, options_to_json
PASSWORD_FIELD = UserSchema(only=("password",)).fields["password"] 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") @view_config(route_name="settings_filters", renderer="settings_filters.jinja2")
def get_settings_filters(request: Request) -> dict: def get_settings_filters(request: Request) -> dict:
"""Generate the filters settings page.""" """Generate the filters settings page."""

Loading…
Cancel
Save