Skip to content
Commits on Source (3)
......@@ -59,7 +59,7 @@ class UserAdmin(BaseUserAdmin):
actions = ['list_permissions']
fieldsets = [
(None, {
'fields': ['username', 'token', 'password']
'fields': ['username', 'token', 'fido2_credential_data', 'password']
}),
(_('personal info'), {
'fields': ['first_name', 'last_name', 'email', 'language']
......
# Generated by Django 3.2.4 on 2021-06-16 15:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('castellum_auth', '0003_first_name_length'),
]
operations = [
migrations.AddField(
model_name='user',
name='fido2_credential_data',
field=models.TextField(blank=True),
),
]
......@@ -45,6 +45,7 @@ class User(AbstractUser):
_('Time until automatic logout'), default=settings.LOGOUT_TIMEOUT_DEFAULT
)
token = models.CharField(max_length=64, unique=True, default=generate_token)
fido2_credential_data = models.TextField(blank=True)
class Meta:
ordering = ['username']
......
......@@ -19,13 +19,33 @@
# License along with Castellum. If not, see
# <http://www.gnu.org/licenses/>.
from django.conf import settings
from django.contrib.auth import login
from django.contrib.auth.views import LoginView as DjangoLoginView
from django.http import Http404
from django.http import HttpResponse
from django.shortcuts import redirect
from django.utils.translation import check_for_language
from fido2 import cbor
from fido2.client import ClientData
from fido2.ctap2 import AttestationObject
from fido2.ctap2 import AttestedCredentialData
from fido2.ctap2 import AuthenticatorData
from fido2.server import Fido2Server
from fido2.utils import websafe_decode
from fido2.utils import websafe_encode
from fido2.webauthn import PublicKeyCredentialRpEntity
from stronghold.decorators import public
from castellum.utils.views import get_next_url
from .models import User
LANGUAGE_QUERY_PARAMETER = 'language'
fido2 = Fido2Server(PublicKeyCredentialRpEntity(settings.DOMAIN, settings.SITE_TITLE))
def set_language(request):
"""Save language in the user model.
......@@ -39,3 +59,84 @@ def set_language(request):
request.user.save()
return redirect(get_next_url(request))
class LoginView(DjangoLoginView):
def form_valid(self, form):
if settings.CASTELLUM_REQUIRE_FIDO2:
user = form.get_user()
self.request.session['fido2_user'] = {
'pk': user.pk,
'backend': user.backend,
}
return self.render_to_response(self.get_context_data(
fido2_required=True,
success_url=self.get_success_url(),
))
else:
return super().form_valid(form)
def get_credentials(user):
raw = user.fido2_credential_data
if not raw:
raise Http404
return [AttestedCredentialData(websafe_decode(raw)))]
def register_begin(request):
registration_data, state = fido2.register_begin(
{
'id': str(request.user.id).encode('utf-8'),
'name': request.user.username,
'displayName': request.user.get_full_name(),
},
[],
)
request.session['fido2_state'] = state
return HttpResponse(cbor.encode(registration_data), content_type='application/cbor')
def register_complete(request):
data = cbor.decode(request.body)
auth_data = fido2.register_complete(
request.session.pop('fido2_state'),
ClientData(data['clientData']),
AttestationObject(data['attestationObject']),
)
request.user.fido2_credential_data = websafe_encode(auth_data.credential_data)
request.user.save()
return HttpResponse()
@public
def authenticate_begin(request):
user = User.objects.get(pk=request.session['fido2_user']['pk'])
auth_data, state = fido2.authenticate_begin(get_credentials(user))
request.session['fido2_state'] = state
return HttpResponse(cbor.encode(auth_data), content_type='application/cbor')
@public
def authenticate_complete(request):
user_data = request.session.pop('fido2_user')
user = User.objects.get(pk=user_data['pk'])
user.backend = user_data['backend']
data = cbor.decode(request.body)
fido2.authenticate_complete(
request.session.pop('fido2_state'),
get_credentials(user),
data['credentialId'],
ClientData(data['clientData']),
AuthenticatorData(data['authenticatorData']),
data['signature'],
)
login(request, user)
return HttpResponse()
......@@ -238,6 +238,9 @@ NPM_FILE_PATTERNS = {
'main.css',
'locales/de.js',
],
'cbor-js': [
'cbor.js',
],
}
BOOTSTRAP4 = {
......@@ -457,3 +460,5 @@ CASTELLUM_ATTRIBUTE_EXPORTER = 'castellum.recruitment.attribute_exporters.JSONEx
SCHEDULER_URL = ''
SCHEDULER_TOKEN = ''
CASTELLUM_REQUIRE_FIDO2 = False
(function() {
var csrfToken = document.querySelector('[name="csrfmiddlewaretoken"]').value;
var raiseForStatus = function(response) {
if (!response.ok) {
throw new Error(response.statusText);
} else {
return response;
}
};
var fromCBOR = function(response) {
return Promise.resolve(response)
.then(raiseForStatus)
.then(response => response.arrayBuffer())
.then(CBOR.decode);
};
var register = function() {
return fetch('/fido2/register/begin/', {credentials: 'same-origin'})
.then(fromCBOR)
.then(options => navigator.credentials.create(options))
.then(attestation => fetch('/fido2/register/complete/', {
method: 'POST',
credentials: 'same-origin',
headers: {'X-CSRFToken': csrfToken},
body: CBOR.encode({
'attestationObject': new Uint8Array(attestation.response.attestationObject),
'clientData': new Uint8Array(attestation.response.clientDataJSON),
}),
}))
.then(raiseForStatus)
.catch(alert)
.then(() => alert(django.gettext('second factor has been registered')));
};
var authenticate = function() {
return fetch('/fido2/authenticate/begin/', {credentials: 'same-origin'})
.then(fromCBOR)
.then(options => navigator.credentials.get(options))
.then(assertion => fetch('/fido2/authenticate/complete/', {
method: 'POST',
credentials: 'same-origin',
headers: {'X-CSRFToken': csrfToken},
body: CBOR.encode({
'credentialId': new Uint8Array(assertion.rawId),
'authenticatorData': new Uint8Array(assertion.response.authenticatorData),
'clientData': new Uint8Array(assertion.response.clientDataJSON),
'signature': new Uint8Array(assertion.response.signature),
}),
}))
.then(raiseForStatus)
.catch(alert);
};
$$.on(document, 'click', '[data-js="fido2-register"]', register);
$$.on(document, 'click', '[data-js="fido2-authenticate"]', authenticate);
var fido2Auto = document.querySelector('[data-js="fido2-auto"]');
if (fido2Auto) {
authenticate().then(() => {
window.location = fido2Auto.dataset.successUrl;
});
}
})();
......@@ -69,4 +69,10 @@
</div>
{% endif %}
</div>
{% csrf_token %}
<button type="button" data-js="fido2-register">register</button>
<button type="button" data-js="fido2-authenticate">authenticate</button>
<script src="{% static 'cbor-js/cbor.js' %}"></script>
<script src="{% static 'js/fido2.js' %}"></script>
{% endblock %}
{% extends "base.html" %}
{% load i18n bootstrap4 %}
{% load static i18n bootstrap4 %}
{% block title %}{% translate "Log in" %} &middot; {{ block.super }}{% endblock %}
{% block content %}
{% if fido2_required %}
<div class="alert alert-success" role="alert" data-js="fido2-auto" data-success-url="{{ success_url }}">
{% translate 'Two factor authentication required' %}
</div>
{% endif %}
<form method="post">
{% include 'utils/form_errors.html' with form=form %}
{% csrf_token %}
......@@ -17,3 +23,10 @@
</div>
</form>
{% endblock %}
{% block extra_scripts %}
{% if fido2_required %}
<script src="{% static 'cbor-js/cbor.js' %}"></script>
<script src="{% static 'js/fido2.js' %}"></script>
{% endif %}
{% endblock %}
......@@ -25,7 +25,6 @@ from django.conf import settings
from django.contrib import admin
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView
from django.contrib.auth.views import LogoutView
from django.http import HttpResponse
from django.urls import include
......@@ -38,6 +37,11 @@ from django.views.static import serve
from stronghold.decorators import public
from castellum.castellum_auth.forms import AuthenticationForm
from castellum.castellum_auth.views import LoginView
from castellum.castellum_auth.views import authenticate_begin
from castellum.castellum_auth.views import authenticate_complete
from castellum.castellum_auth.views import register_begin
from castellum.castellum_auth.views import register_complete
from castellum.castellum_auth.views import set_language
from castellum.studies.models import Resource
from castellum.studies.models import Study
......@@ -103,6 +107,10 @@ urlpatterns = [
path(
'data-protection/', include('castellum.data_protection.urls', namespace='data_protection')
),
path('fido2/register/begin/', register_begin),
path('fido2/register/complete/', register_complete),
path('fido2/authenticate/begin/', authenticate_begin),
path('fido2/authenticate/complete/', authenticate_complete),
]
if settings.PROTECTED_MEDIA_SERVER:
......
......@@ -6,6 +6,7 @@
"@fortawesome/fontawesome-free": "^5.15.3",
"@ttskch/select2-bootstrap4-theme": "^1.5.2",
"bootstrap": "^4.6.0",
"cbor-js": "^0.1.0",
"fullcalendar-scheduler": "^5.7.2",
"jdenticon": "^3.1.0",
"jquery": "^3.6.0",
......@@ -21,12 +22,14 @@
"ecmaVersion": 6
},
"env": {
"browser": true
"browser": true,
"es6": true
},
"extends": "eslint:recommended",
"globals": {
"$": "readonly",
"$$": "readonly",
"CBOR": "readonly",
"django": "readonly",
"FullCalendar": "readonly",
"Datepicker": "readonly"
......
......@@ -20,6 +20,7 @@ install_requires =
django-phonenumber-field == 5.2.0
django-stronghold == 0.4.0
Faker == 8.5.1
fido2 == 0.9.1
file-magic == 0.4.0
geopy == 2.1.0
icalendar == 4.0.7
......