Many broken things

This commit is contained in:
Jens Langhammer 2018-11-16 09:10:35 +01:00
parent 79490984d1
commit fbaab4efaf
No known key found for this signature in database
GPG Key ID: BEBC05297D92821B
104 changed files with 3056 additions and 63 deletions

View File

View File

View File

@ -0,0 +1,8 @@
# from django.conf.urls import url, include
# # Add this!
# from passbook.admin.api.v1.source import SourceResource
# urlpatterns = [
# url(r'source/', include(SourceResource.urls())),
# ]

View File

@ -0,0 +1,26 @@
# from rest_framework.serializers import HyperlinkedModelSerializer
# from passbook.admin.api.v1.utils import LookupSerializer
# from passbook.core.models import Source
# from passbook.oauth_client.models import OAuthSource
# from rest_framework.viewsets import ModelViewSet
# class LookupSourceSerializer(HyperlinkedModelSerializer):
# def to_representation(self, instance):
# if isinstance(instance, Source):
# return SourceSerializer(instance=instance).data
# elif isinstance(instance, OAuthSource):
# return OAuthSourceSerializer(instance=instance).data
# else:
# return LookupSourceSerializer(instance=instance).data
# class Meta:
# model = Source
# fields = '__all__'
# class SourceViewSet(ModelViewSet):
# serializer_class = LookupSourceSerializer
# queryset = Source.objects.select_subclasses()

View File

@ -0,0 +1,17 @@
from django.db.models import Model
from rest_framework.serializers import ModelSerializer
class LookupSerializer(ModelSerializer):
mapping = {}
def to_representation(self, instance):
for __model, __serializer in self.mapping.items():
if isinstance(instance, __model):
return __serializer(instance=instance).to_representation(instance)
raise KeyError(instance.__class__.__name__)
class Meta:
model = Model
fields = '__all__'

9
passbook/admin/mixins.py Normal file
View File

@ -0,0 +1,9 @@
from django.contrib.auth.mixins import UserPassesTestMixin
class AdminRequiredMixin(UserPassesTestMixin):
"""Make sure user is administrator"""
def test_func(self):
return self.request.user.is_superuser

View File

@ -0,0 +1 @@
django-crispy-forms

View File

@ -0,0 +1,647 @@
{% extends "administration/base.html" %}
{% block content %}
<!-- Toolbar -->
<div class="row toolbar-pf table-view-pf-toolbar" id="toolbar1">
<div class="col-sm-12">
<form class="toolbar-pf-actions">
<div class="form-group toolbar-pf-filter">
<label class="sr-only" for="filter">Rendering Engine</label>
<div class="input-group">
<div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" id="filter" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Rendering Engine <span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href="#" id="filter1">Rendering Engine</a></li>
<li><a href="#" id="filter2">Browser</a></li>
<li><a href="#" id="filter3">Platform(s)</a></li>
<li><a href="#" id="filter4">Engine Version</a></li>
<li><a href="#" id="filter5">CSS Grade</a></li>
</ul>
</div>
<input type="text" class="form-control" placeholder="Filter By Rendering Engine..." autocomplete="off" id="filterInput">
</div>
</div>
<div class="form-group">
<button class="btn btn-default" type="button" id="deleteRows1">Delete Rows</button>
<button class="btn btn-default" type="button" id="restoreRows1" disabled>Restore Rows</button>
<div class="dropdown btn-group dropdown-kebab-pf">
<button class="btn btn-link dropdown-toggle" type="button" id="dropdownKebab" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
<span class="fa fa-ellipsis-v"></span>
</button>
<ul class="dropdown-menu " aria-labelledby="dropdownKebab">
<li><a href="#">Action</a></li>
<li><a href="#">Another Action</a></li>
<li><a href="#">Something Else Here</a></li>
<li role="separator" class="divider"></li>
<li><a href="#">Separated Link</a></li>
</ul>
</div>
</div>
<div class="toolbar-pf-action-right">
<div class="form-group toolbar-pf-find">
<button class="btn btn-link btn-find" type="button">
<span class="fa fa-search"></span>
</button>
<div class="find-pf-dropdown-container">
<input type="text" class="form-control" id="find" placeholder="Find By Keyword...">
<div class="find-pf-buttons">
<span class="find-pf-nums">1 of 3</span>
<button class="btn btn-link" type="button">
<span class="fa fa-angle-up"></span>
</button>
<button class="btn btn-link" type="button">
<span class="fa fa-angle-down"></span>
</button>
<button class="btn btn-link btn-find-close" type="button">
<span class="pficon pficon-close"></span>
</button>
</div>
</div>
</div>
</div>
</form>
<div class="row toolbar-pf-results">
<div class="col-sm-9">
<div class="hidden">
<h5>0 Results</h5>
<p>Active filters:</p>
<ul class="list-inline"></ul>
<p><a href="#">Clear All Filters</a></p>
</div>
</div>
<div class="col-sm-3 table-view-pf-select-results">
<strong>0</strong> of <strong>0</strong> selected
</div>
</div>
</div>
</div>
<!-- Table HTML -->
<table class="table table-striped table-bordered table-hover" id="table1">
<thead>
<tr>
<th><label class="sr-only" for="selectAll">Select all rows</label><input type="checkbox" id="selectAll" name="selectAll"></th>
<th>Rendering Engine</th>
<th>Browser</th>
<th>Platform(s)</th>
<th>Engine Version</th>
<th>CSS Grade</th>
<th colspan="2">Actions</th>
</tr>
</thead>
</table>
<form class="content-view-pf-pagination table-view-pf-pagination clearfix" id="pagination1">
<div class="form-group">
<select class="selectpicker pagination-pf-pagesize">
<option value="6">6</option>
<option value="10" >10</option>
<option value="15" selected="selected">15</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
<span>per page</span>
</div>
<div class="form-group">
<span><span class="pagination-pf-items-current">1-15</span> of <span class="pagination-pf-items-total">75</span></span>
<ul class="pagination pagination-pf-back">
<li class="disabled"><a href="#" title="First Page"><span class="i fa fa-angle-double-left"></span></a></li>
<li class="disabled"><a href="#" title="Previous Page"><span class="i fa fa-angle-left"></span></a></li>
</ul>
<label for="pagination1-page" class="sr-only">Current Page</label>
<input class="pagination-pf-page" type="text" value="1" id="pagination1-page"/>
<span>of <span class="pagination-pf-pages">5</span></span>
<ul class="pagination pagination-pf-forward">
<li><a href="#" title="Next Page"><span class="i fa fa-angle-right"></span></a></li>
<li><a href="#" title="Last Page"><span class="i fa fa-angle-double-right"></span></a></li>
</ul>
</div>
</form>
<!-- Blank Slate HTML -->
<div class="blank-slate-pf table-view-pf-empty hidden" id="emptyState1">
<div class="blank-slate-pf-icon">
<span class="pficon pficon pficon-add-circle-o"></span>
</div>
<h1>
Empty State Title
</h1>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</p>
<p>
Learn more about this <a href="#">in the documentation</a>.
</p>
<div class="blank-slate-pf-main-action">
<button class="btn btn-primary btn-lg"> Main Action </button>
</div>
<div class="blank-slate-pf-secondary-action">
<button class="btn btn-default">Secondary Action</button>
<button class="btn btn-default">Secondary Action</button>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
$(document).ready(function () {
// JSON data for Table View
var dataSet = [{
engine: "Trident",
browser: "Internet Explorer 4.0",
platforms: "Win 95+",
version: "4",
grade: "X"
}, {
engine: "Trident",
browser: "Internet Explorer 5.0",
platforms: "Win 95+",
version: "5",
grade: "C"
}, {
engine: "Trident",
browser: "Internet Explorer 5.5",
platforms: "Win 95+",
version: "5.5",
grade: "A"
}, {
engine: "Trident",
browser: "Internet Explorer 6",
platforms: "Win 98+",
version: "6",
grade: "A"
}, {
engine: "Trident",
browser: "Internet Explorer 7",
platforms: "Win XP SP2+",
version: "7",
grade: "A"
}, {
engine: "Trident",
browser: "AOL browser (AOL desktop)",
platforms: "Win XP",
version: "6",
grade: "A"
}, {
engine: "Gecko",
browser: "Firefox 1.0",
platforms: "Win 98+ / OSX.2+",
version: "1.7",
grade: "A"
}, {
engine: "Gecko",
browser: "Firefox 1.5",
platforms: "Win 98+ / OSX.2+",
version: "1.8",
grade: "A"
}, {
engine: "Gecko",
browser: "Firefox 2.0",
platforms: "Win 98+ / OSX.2+",
version: "1.8",
grade: "A"
}, {
engine: "Gecko",
browser: "Firefox 3.0",
platforms: "Win 2k+ / OSX.3+",
version: "1.9",
grade: "A"
}, {
engine: "Gecko",
browser: "Camino 1.0",
platforms: "OSX.2+",
version: "1.8",
grade: "A"
}, {
engine: "Gecko",
browser: "Camino 1.5",
platforms: "OSX.3+",
version: "1.8",
grade: "A"
}, {
engine: "Gecko",
browser: "Netscape 7.2",
platforms: "Win 95+ / Mac OS 8.6-9.2",
version: "1.7",
grade: "A"
}, {
engine: "Gecko",
browser: "Netscape Browser 8",
platforms: "Win 98SE+",
version: "1.7",
grade: "A"
}, {
engine: "Gecko",
browser: "Netscape Navigator 9",
platforms: "Win 98+ / OSX.2+",
version: "1.8",
grade: "A"
}, {
engine: "Gecko",
browser: "Mozilla 1.0",
platforms: "Win 95+ / OSX.1+",
version: "1",
grade: "A"
}, {
engine: "Gecko",
browser: "Mozilla 1.1",
platforms: "Win 95+ / OSX.1+",
version: "1.1",
grade: "A"
}, {
engine: "Gecko",
browser: "Mozilla 1.2",
platforms: "Win 95+ / OSX.1+",
version: "1.2",
grade: "A"
}, {
engine: "Gecko",
browser: "Mozilla 1.3",
platforms: "Win 95+ / OSX.1+",
version: "1.3",
grade: "A"
}, {
engine: "Gecko",
browser: "Mozilla 1.4",
platforms: "Win 95+ / OSX.1+",
version: "1.4",
grade: "A"
}, {
engine: "Gecko",
browser: "Mozilla 1.5",
platforms: "Win 95+ / OSX.1+",
version: "1.5",
grade: "A"
}, {
engine: "Gecko",
browser: "Mozilla 1.6",
platforms: "Win 95+ / OSX.1+",
version: "1.6",
grade: "A"
}, {
engine: "Gecko",
browser: "Mozilla 1.7",
platforms: "Win 98+ / OSX.1+",
version: "1.7",
grade: "A"
}, {
engine: "Gecko",
browser: "Mozilla 1.8",
platforms: "Win 98+ / OSX.1+",
version: "1.8",
grade: "A"
}, {
engine: "Gecko",
browser: "Seamonkey 1.1",
platforms: "Win 98+ / OSX.2+",
version: "1.8",
grade: "A"
}, {
engine: "Gecko",
browser: "Epiphany 2.20",
platforms: "Gnome",
version: "1.8",
grade: "A"
}, {
engine: "Webkit",
browser: "Safari 1.2",
platforms: "OSX.3",
version: "125.5",
grade: "A"
}, {
engine: "Webkit",
browser: "Safari 1.3",
platforms: "OSX.3",
version: "312.8",
grade: "A"
}, {
engine: "Webkit",
browser: "Safari 2.0",
platforms: "OSX.4+",
version: "419.3",
grade: "A"
}, {
engine: "Webkit",
browser: "Safari 3.0",
platforms: "OSX.4+",
version: "522.1",
grade: "A"
}, {
engine: "Webkit",
browser: "OmniWeb 5.5",
platforms: "OSX.4+",
version: "420",
grade: "A"
}, {
engine: "Webkit",
browser: "iPod Touch / iPhone",
platforms: "iPod",
version: "420.1",
grade: "A"
}, {
engine: "Webkit",
browser: "S60",
platforms: "S60",
version: "413",
grade: "A"
}, {
engine: "Presto",
browser: "Opera 7.0",
platforms: "Win 95+ / OSX.1+",
version: "-",
grade: "A"
}, {
engine: "Presto",
browser: "Opera 7.5",
platforms: "Win 95+ / OSX.2+",
version: "-",
grade: "A"
}, {
engine: "Presto",
browser: "Opera 8.0",
platforms: "Win 95+ / OSX.2+",
version: "-",
grade: "A"
}, {
engine: "Presto",
browser: "Opera 8.5",
platforms: "Win 95+ / OSX.2+",
version: "-",
grade: "A"
}, {
engine: "Presto",
browser: "Opera 9.0",
platforms: "Win 95+ / OSX.3+",
version: "-",
grade: "A"
}, {
engine: "Presto",
browser: "Opera 9.2",
platforms: "Win 88+ / OSX.3+",
version: "-",
grade: "A"
}, {
engine: "Presto",
browser: "Opera 9.5",
platforms: "Win 88+ / OSX.3+",
version: "-",
grade: "A"
}, {
engine: "Presto",
browser: "Opera for Wii",
platforms: "Wii",
version: "-",
grade: "A"
}, {
engine: "Presto",
browser: "Nokia N800",
platforms: "N800",
version: "-",
grade: "A"
}, {
engine: "Presto",
browser: "Nintendo DS browser",
platforms: "Nintendo DS",
version: "8.5",
grade: "C/A<sup>1</sup>"
}, {
engine: "KHTML",
browser: "Konqureror 3.1",
platforms: "KDE 3.1",
version: "3.1",
grade: "C"
}, {
engine: "KHTML",
browser: "Konqureror 3.3",
platforms: "KDE 3.3",
version: "3.3",
grade: "A"
}, {
engine: "KHTML",
browser: "Konqureror 3.5",
platforms: "KDE 3.5",
version: "3.5",
grade: "A"
}, {
engine: "Tasman",
browser: "Internet Explorer 4.5",
platforms: "Mac OS 8-9",
version: "-",
grade: "X"
}, {
engine: "Tasman",
browser: "Internet Explorer 5.1",
platforms: "Mac OS 7.6-9",
version: "1",
grade: "C"
}, {
engine: "Tasman",
browser: "Internet Explorer 5.2",
platforms: "Mac OS 8-X",
version: "1",
grade: "C"
}, {
engine: "Misc",
browser: "NetFront 3.1",
platforms: "Embedded devices",
version: "-",
grade: "C"
}, {
engine: "Misc",
browser: "NetFront 3.4",
platforms: "Embedded devices",
version: "-",
grade: "A"
}, {
engine: "Misc",
browser: "Dillo 0.8",
platforms: "Embedded devices",
version: "-",
grade: "X"
}, {
engine: "Misc",
browser: "Links",
platforms: "Text only",
version: "-",
grade: "X"
}, {
engine: "Misc",
browser: "Lynx",
platforms: "Text only",
version: "-",
grade: "X"
}, {
engine: "Misc",
browser: "IE Mobile",
platforms: "Windows Mobile 6",
version: "-",
grade: "C"
}, {
engine: "Misc",
browser: "PSP browser",
platforms: "PSP",
version: "-",
grade: "C"
}, {
engine: "Other browsers",
browser: "All others",
platforms: "-",
version: "-",
grade: "U"
}];
// DataTable Config
$("#table1").DataTable({
columns: [
{
data: null,
className: "table-view-pf-select",
render: function (data, type, full, meta) {
// Select row checkbox renderer
var id = "select" + meta.row;
return '<label class="sr-only" for="' + id + '">Select row ' + meta.row +
'</label><input type="checkbox" id="' + id + '" name="' + id + '">';
},
sortable: false
},
{ data: "engine" },
{ data: "browser" },
{ data: "platforms" },
{ data: "version" },
{ data: "grade" },
{
data: null,
className: "table-view-pf-actions",
render: function (data, type, full, meta) {
// Inline action button renderer
return '<div class="table-view-pf-btn"><button class="btn btn-default" type="button">Actions</button></div>';
}
}, {
data: null,
className: "table-view-pf-actions",
render: function (data, type, full, meta) {
// Inline action kebab renderer
return '<div class="dropdown dropdown-kebab-pf">' +
'<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">' +
'<span class="fa fa-ellipsis-v"></span></button>' +
'<ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownKebabRight">' +
'<li><a href="#">Action</a></li>' +
'<li><a href="#">Another action</a></li>' +
'<li><a href="#">Something else here</a></li>' +
'<li role="separator" class="divider"></li>' +
'<li><a href="#">Separated link</a></li></ul></div>';
}
}
],
data: dataSet,
dom: "t",
language: {
zeroRecords: "No records found"
},
order: [[1, 'asc']],
pfConfig: {
emptyStateSelector: "#emptyState1",
filterCaseInsensitive: true,
filterCols: [
null,
{
default: true,
optionSelector: "#filter1",
placeholder: "Filter By Rendering Engine..."
}, {
optionSelector: "#filter2",
placeholder: "Filter By Browser..."
}, {
optionSelector: "#filter3",
placeholder: "Filter By Platform(s)..."
}, {
optionSelector: "#filter4",
placeholder: "Filter By Engine Version..."
}, {
optionSelector: "#filter5",
placeholder: "Filter By CSS Grade..."
}
],
paginationSelector: "#pagination1",
toolbarSelector: "#toolbar1",
selectAllSelector: 'th:first-child input[type="checkbox"]',
colvisMenuSelector: '.table-view-pf-colvis-menu'
},
select: {
selector: 'td:first-child input[type="checkbox"]',
style: 'multi'
},
});
/**
* Utility to show empty Table View
*
* @param {object} config - Config properties associated with a Table View
* @param {object} config.data - Data set for DataTable
* @param {string} config.deleteRowsSelector - Selector for delete rows control
* @param {string} config.restoreRowsSelector - Selector for restore rows control
* @param {string} config.tableSelector - Selector for the HTML table
*/
var emptyTableViewUtil = function (config) {
var self = this;
this.dt = $(config.tableSelector).DataTable(); // DataTable
this.deleteRows = $(config.deleteRowsSelector); // Delete rows control
this.restoreRows = $(config.restoreRowsSelector); // Restore rows control
// Handle click on delete rows control
this.deleteRows.on('click', function () {
self.dt.clear().draw();
$(self.restoreRows).prop("disabled", false);
});
// Handle click on restore rows control
this.restoreRows.on('click', function () {
self.dt.rows.add(config.data).draw();
$(this).prop("disabled", true);
});
// Initialize restore rows
if (this.dt.data().length === 0) {
$(this.restoreRows).prop("disabled", false);
}
};
// Initialize empty Table View util
new emptyTableViewUtil({
data: dataSet,
deleteRowsSelector: "#deleteRows1",
restoreRowsSelector: "#restoreRows1",
tableSelector: "#table1"
});
/**
* Utility to find items in Table View
*/
var findTableViewUtil = function (config) {
// Upon clicking the find button, show the find dropdown content
$(".btn-find").click(function () {
$(this).parent().find(".find-pf-dropdown-container").toggle();
});
// Upon clicking the find close button, hide the find dropdown content
$(".btn-find-close").click(function () {
$(".find-pf-dropdown-container").hide();
});
};
// Initialize find util
new findTableViewUtil();
});
</script>
<script>
// Initialize Datatables
$(document).ready(function () {
$('.datatable').dataTable();
});
</script>
{% endblock %}

View File

@ -5,11 +5,14 @@
{% block nav_secondary %} {% block nav_secondary %}
<ul class="nav navbar-nav navbar-persistent"> <ul class="nav navbar-nav navbar-persistent">
<li class="active"> <li class="{% is_active 'passbook_admin:overview' %}">
<a href="#">{% trans 'Overview' %}</a> <a href="{% url 'passbook_admin:overview' %}">{% trans 'Overview' %}</a>
</li> </li>
<li class="{% is_active 'applications'}"> <li class="{% is_active 'passbook_admin:applications' %}">
<a href="#">{% trans 'Applications' %}</a> <a href="{% url 'passbook_admin:applications' %}">{% trans 'Applications' %}</a>
</li>
<li class="{% is_active 'passbook_admin:sources' %}">
<a href="{% url 'passbook_admin:sources' %}">{% trans 'Sources' %}</a>
</li> </li>
<li> <li>
<a href="#">{% trans 'Rules' %}</a> <a href="#">{% trans 'Rules' %}</a>

View File

@ -1,5 +0,0 @@
{% extends "administration/base.html" %}
{% block content %}
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "generic/list.html" %}
{% load i18n %}
{% block above_table %}
<div class="dropdown">
<button class="btn btn-primary dropdown-toggle" type="button" id="createDropdown" data-toggle="dropdown">
{% trans 'Create...' %}
<span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu" aria-labelledby="createDropdown">
{% for type, name in types.items %}
<li role="presentation"><a role="menuitem" tabindex="-1" href="{% url 'passbook_admin:source-create' %}?type={{ type }}">{{ name }}</a></li>
{% endfor %}
</ul>
</div>
<hr>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends "generic/form.html" %}
{% load i18n %}
{% block above_form %}
<h1>{% trans 'Create' %}</h1>
{% endblock %}

View File

@ -0,0 +1,16 @@
{% extends "administration/base.html" %}
{% load crispy_forms_tags %}
{% load i18n %}
{% block content %}
<div class="container">
{% block above_form %}
{% endblock %}
<form action="" method="post">
{% csrf_token %}
{{ form|crispy }}
<input type="submit" class="btn btn-primary" value="{% trans 'Update' %}" />
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "administration/base.html" %}
{% load i18n %}
{% load utils %}
{% block content %}
<div class="container">
{% block above_table %}
{% endblock %}
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>{% trans 'Name' %}</th>
<th>{% trans 'Class' %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for source in object_list %}
<tr>
<td>{{ source.name }}</td>
<td>{{ source.cast|fieldtype }}</td>
<td><a href="{% url 'passbook_admin:source-update' pk=source.pk %}">{% trans 'Edit' %}</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% extends "generic/form.html" %}
{% load i18n %}
{% block above_form %}
<h1>{% trans 'Update' %}</h1>
{% endblock %}

View File

@ -1,11 +1,19 @@
"""passbook URL Configuration""" """passbook URL Configuration"""
from django.urls import path from django.urls import include, path
from passbook.admin.views import applications, overview from passbook.admin.views import applications, overview, sources
urlpatterns = [ urlpatterns = [
path('', overview.AdministrationOverviewView.as_view(), name='admin-overview'), path('', overview.AdministrationOverviewView.as_view(), name='overview'),
path('applications/', applications.ApplicationListView.as_view(), name='admin-applications'), path('applications/', applications.ApplicationListView.as_view(),
name='applications'),
path('applications/create/', applications.ApplicationCreateView.as_view(), path('applications/create/', applications.ApplicationCreateView.as_view(),
name='admin-application-create'), name='application-create'),
path('sources/', sources.SourceListView.as_view(),
name='sources'),
path('sources/create/', sources.SourceCreateView.as_view(),
name='source-create'),
path('sources/<uuid:pk>/', sources.SourceUpdateView.as_view(),
name='source-update'),
# path('api/v1/', include('passbook.admin.api.v1.urls'))
] ]

View File

@ -2,14 +2,16 @@
from django.views.generic import CreateView, DeleteView, ListView, UpdateView from django.views.generic import CreateView, DeleteView, ListView, UpdateView
from passbook.admin.mixins import AdminRequiredMixin
from passbook.core.models import Application from passbook.core.models import Application
class ApplicationListView(ListView): class ApplicationListView(AdminRequiredMixin, ListView):
model = Application model = Application
template_name = 'administration/list.html' template_name = 'administration/application/list.html'
class ApplicationCreateView(CreateView):
class ApplicationCreateView(AdminRequiredMixin, CreateView):
model = Application model = Application
template_name = 'administration/application/create.html' template_name = 'administration/application/create.html'

View File

@ -1,10 +1,11 @@
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView from django.views.generic import TemplateView
from passbook.admin.mixins import AdminRequiredMixin
from passbook.core.models import Application, Rule, User from passbook.core.models import Application, Rule, User
class AdministrationOverviewView(LoginRequiredMixin, TemplateView): class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
template_name = 'administration/overview.html' template_name = 'administration/overview.html'

View File

@ -0,0 +1,49 @@
"""passbook Source administration"""
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.views.generic import CreateView, DeleteView, ListView, UpdateView
from passbook.admin.mixins import AdminRequiredMixin
from passbook.core.models import Source
from passbook.lib.utils.reflection import path_to_class
class SourceListView(AdminRequiredMixin, ListView):
model = Source
template_name = 'administration/source/list.html'
def get_context_data(self, **kwargs):
kwargs['types'] = {
x.__name__: x._meta.verbose_name for x in Source.__subclasses__()}
return super().get_context_data(**kwargs)
class SourceCreateView(SuccessMessageMixin, AdminRequiredMixin, CreateView):
template_name = 'generic/create.html'
success_url = reverse_lazy('passbook_admin:sources')
success_message = _('Successfully created Source')
def get_form_class(self):
source_type = self.request.GET.get('type')
model = next(x if x.__name__ == source_type else None for x in Source.__subclasses__())
return path_to_class(model.form)
class SourceUpdateView(SuccessMessageMixin, AdminRequiredMixin, UpdateView):
model = Source
template_name = 'generic/update.html'
success_url = reverse_lazy('passbook_admin:sources')
success_message = _('Successfully updated Source')
def get_form_class(self):
form_class_path = self.get_object().form
form_class = path_to_class(form_class_path)
return form_class
def get_object(self, queryset=None):
obj = Source.objects.get(pk=self.kwargs.get('pk'))
return obj.cast()

View File

@ -1,11 +1,13 @@
# Generated by Django 2.1.3 on 2018-11-11 08:22 # Generated by Django 2.1.3 on 2018-11-11 14:06
import uuid
from django.conf import settings
import django.contrib.auth.models import django.contrib.auth.models
import django.contrib.auth.validators import django.contrib.auth.validators
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -45,9 +47,9 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name='Application', name='Application',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateField(auto_now_add=True)), ('created', models.DateField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True)), ('last_updated', models.DateTimeField(auto_now=True)),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.TextField()), ('name', models.TextField()),
('launch_url', models.URLField(blank=True, null=True)), ('launch_url', models.URLField(blank=True, null=True)),
('icon_url', models.TextField(blank=True, null=True)), ('icon_url', models.TextField(blank=True, null=True)),
@ -59,9 +61,9 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name='Rule', name='Rule',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateField(auto_now_add=True)), ('created', models.DateField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True)), ('last_updated', models.DateTimeField(auto_now=True)),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.TextField(blank=True, null=True)), ('name', models.TextField(blank=True, null=True)),
('action', models.CharField(choices=[('allow', 'allow'), ('deny', 'deny')], max_length=20)), ('action', models.CharField(choices=[('allow', 'allow'), ('deny', 'deny')], max_length=20)),
('negate', models.BooleanField(default=False)), ('negate', models.BooleanField(default=False)),
@ -73,9 +75,9 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name='Source', name='Source',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateField(auto_now_add=True)), ('created', models.DateField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True)), ('last_updated', models.DateTimeField(auto_now=True)),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.TextField()), ('name', models.TextField()),
('slug', models.SlugField()), ('slug', models.SlugField()),
('enabled', models.BooleanField(default=True)), ('enabled', models.BooleanField(default=True)),

View File

@ -5,8 +5,9 @@ from logging import getLogger
import reversion import reversion
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from model_utils.managers import InheritanceManager
from passbook.lib.models import CastableModel, CreatedUpdatedModel from passbook.lib.models import CreatedUpdatedModel, UUIDModel
LOGGER = getLogger(__name__) LOGGER = getLogger(__name__)
@ -17,7 +18,7 @@ class User(AbstractUser):
sources = models.ManyToManyField('Source', through='UserSourceConnection') sources = models.ManyToManyField('Source', through='UserSourceConnection')
@reversion.register() @reversion.register()
class Application(CastableModel, CreatedUpdatedModel): class Application(UUIDModel, CreatedUpdatedModel):
"""Every Application which uses passbook for authentication/identification/authorization """Every Application which uses passbook for authentication/identification/authorization
needs an Application record. Other authentication types can subclass this Model to needs an Application record. Other authentication types can subclass this Model to
add custom fields and other properties""" add custom fields and other properties"""
@ -26,6 +27,8 @@ class Application(CastableModel, CreatedUpdatedModel):
launch_url = models.URLField(null=True, blank=True) launch_url = models.URLField(null=True, blank=True)
icon_url = models.TextField(null=True, blank=True) icon_url = models.TextField(null=True, blank=True)
objects = InheritanceManager()
def user_is_authorized(self, user: User) -> bool: def user_is_authorized(self, user: User) -> bool:
"""Check if user is authorized to use this application""" """Check if user is authorized to use this application"""
raise NotImplementedError() raise NotImplementedError()
@ -34,14 +37,16 @@ class Application(CastableModel, CreatedUpdatedModel):
return self.name return self.name
@reversion.register() @reversion.register()
class Source(CastableModel, CreatedUpdatedModel): class Source(UUIDModel, CreatedUpdatedModel):
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server""" """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
name = models.TextField() name = models.TextField()
slug = models.SlugField() slug = models.SlugField()
form = None # ModelForm-based class ued to create/edit instance form = '' # ModelForm-based class ued to create/edit instance
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)
objects = InheritanceManager()
def __str__(self): def __str__(self):
return self.name return self.name
@ -57,7 +62,7 @@ class UserSourceConnection(CreatedUpdatedModel):
unique_together = (('user', 'source'),) unique_together = (('user', 'source'),)
@reversion.register() @reversion.register()
class Rule(CastableModel, CreatedUpdatedModel): class Rule(UUIDModel, CreatedUpdatedModel):
"""Rules which specify if a user is authorized to use an Application. Can be overridden by """Rules which specify if a user is authorized to use an Application. Can be overridden by
other types to add other fields, more logic, etc.""" other types to add other fields, more logic, etc."""
@ -73,6 +78,8 @@ class Rule(CastableModel, CreatedUpdatedModel):
action = models.CharField(max_length=20, choices=ACTIONS) action = models.CharField(max_length=20, choices=ACTIONS)
negate = models.BooleanField(default=False) negate = models.BooleanField(default=False)
objects = InheritanceManager()
def __str__(self): def __str__(self):
if self.name: if self.name:
return self.name return self.name

View File

@ -2,3 +2,6 @@ django>=2.0
django-reversion django-reversion
PyYAML PyYAML
raven raven
djangorestframework
markdown
django-model-utils

View File

@ -10,10 +10,12 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.1/ref/settings/ https://docs.djangoproject.com/en/2.1/ref/settings/
""" """
import importlib
import os import os
from passbook.lib.config import CONFIG
from passbook import __version__ from passbook import __version__
from passbook.lib.config import CONFIG
VERSION = __version__ VERSION = __version__
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
@ -30,7 +32,7 @@ DEBUG = True
INTERNAL_IPS = ['127.0.0.1'] INTERNAL_IPS = ['127.0.0.1']
ALLOWED_HOSTS = [] ALLOWED_HOSTS = []
LOGIN_URL = 'auth-login' LOGIN_URL = 'passbook_core:auth-login'
# Custom user model # Custom user model
AUTH_USER_MODEL = 'passbook_core.User' AUTH_USER_MODEL = 'passbook_core.User'
@ -41,7 +43,6 @@ AUTHENTICATION_BACKENDS = [
'passbook.oauth_client.backends.AuthorizedServiceBackend' 'passbook.oauth_client.backends.AuthorizedServiceBackend'
] ]
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
@ -54,11 +55,21 @@ INSTALLED_APPS = [
'reversion', 'reversion',
'passbook.core', 'passbook.core',
'passbook.admin', 'passbook.admin',
'rest_framework',
'passbook.lib', 'passbook.lib',
'passbook.ldap', 'passbook.ldap',
'passbook.oauth_client', 'passbook.oauth_client',
'passbook.oauth_provider',
] ]
REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users.
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly'
]
}
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
@ -133,6 +144,8 @@ USE_L10N = True
USE_TZ = True USE_TZ = True
OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application'
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.1/howto/static-files/ # https://docs.djangoproject.com/en/2.1/howto/static-files/
@ -230,3 +243,18 @@ with CONFIG.cd('log'):
if DEBUG: if DEBUG:
INSTALLED_APPS.append('debug_toolbar') INSTALLED_APPS.append('debug_toolbar')
MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware') MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware')
# Load subapps's INSTALLED_APPS
for _app in INSTALLED_APPS:
if _app.startswith('passbook') and \
not _app.startswith('passbook.core'):
if 'apps' in _app:
_app = '.'.join(_app.split('.')[:-2])
try:
app_settings = importlib.import_module("%s.settings" % _app)
INSTALLED_APPS.extend(getattr(app_settings, 'INSTALLED_APPS', []))
MIDDLEWARE.extend(getattr(app_settings, 'MIDDLEWARE', []))
AUTHENTICATION_BACKENDS.extend(getattr(app_settings, 'AUTHENTICATION_BACKENDS', []))
except ImportError:
pass

View File

@ -2,11 +2,9 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load is_active %}
{% block body %} {% block body %}
{% load static %}
{% load i18n %}
<nav class="navbar navbar-default navbar-pf" role="navigation"> <nav class="navbar navbar-default navbar-pf" role="navigation">
<div class="navbar-header"> <div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse-1"> <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse-1">
@ -55,20 +53,21 @@
</ul> </ul>
</li> </li>
</ul> </ul>
<ul class="nav navbar-nav navbar-primary persistent-secondary"> <ul class="nav navbar-nav navbar-primary">
{# FIXME: Detect active application #} {# FIXME: Detect active application #}
<li class="active"> <li class="{% is_active_app 'passbook_core' %}">
<a href="#0">{% trans 'Overview' %}</a> <a href="{% url 'passbook_core:overview' %}">{% trans 'Overview' %}</a>
</li>
<li class="{% is_active_app 'passbook_admin' %}">
<a href="{% url 'passbook_admin:overview' %}">{% trans 'Administration' %}</a>
{% block nav_secondary %} {% block nav_secondary %}
{% endblock %} {% endblock %}
</li> </li>
<li>
<a href="#0">{% trans 'Administration' %}</a>
</li>
</ul> </ul>
</div> </div>
</nav> </nav>
<div class="container-fluid container-cards-pf"> <div class="container-fluid container-cards-pf">
{% include 'partials/messages.html' %}
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</div> </div>

View File

@ -0,0 +1,11 @@
{% if messages %}
{% for msg in messages %}
<div class="alert alert-{{ msg.level_tag }}">
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span class="pficon pficon-close"></span>
</button>
<span class="pficon pficon-{{ msg.level_tag }}"></span>
<strong>{{ msg.message|safe }}</strong>
</div>
{% endfor %}
{% endif %}

View File

@ -8,15 +8,25 @@ from django.views.generic import RedirectView
from passbook.core.views import authentication, overview from passbook.core.views import authentication, overview
admin.autodiscover() admin.autodiscover()
admin.site.login = RedirectView.as_view(pattern_name='auth-login') admin.site.login = RedirectView.as_view(pattern_name='passbook_core:auth-login')
urlpatterns = [ core_urls = [
path('auth/login/', authentication.LoginView.as_view(), name='auth-login'), path('auth/login/', authentication.LoginView.as_view(), name='auth-login'),
path('', overview.OverviewView.as_view(), name='overview'), path('', overview.OverviewView.as_view(), name='overview'),
]
urlpatterns = [
# Core
path('', include((core_urls, 'passbook_core'), namespace='passbook_core')),
# Administration # Administration
path('administration/django/', admin.site.urls), path('administration/django/', admin.site.urls),
path('administration/', include('passbook.admin.urls')), path('administration/',
path('', include('passbook.oauth_client.urls')), include(('passbook.admin.urls', 'passbook_admin'), namespace='passbook_admin')),
path('source/oauth/', include(('passbook.oauth_client.urls',
'passbook_oauth_client'), namespace='passbook_oauth_client')),
path('application/oauth', include(('passbook.oauth_provider.urls',
'passbook_oauth_provider'),
namespace='passbook_oauth_provider')),
] ]
if settings.DEBUG: if settings.DEBUG:

View File

@ -84,7 +84,7 @@ class LoginView(UserPassesTestMixin, FormView):
if 'next' in request.GET: if 'next' in request.GET:
return redirect(request.GET.get('next')) return redirect(request.GET.get('next'))
# Otherwise just index # Otherwise just index
return redirect(reverse('overview')) return redirect(reverse('passbook_core:overview'))
@staticmethod @staticmethod
def invalid_login(request: HttpRequest, disabled_user: User = None) -> HttpResponse: def invalid_login(request: HttpRequest, disabled_user: User = None) -> HttpResponse:

View File

@ -1,8 +1,11 @@
"""passbook oauth_client config""" """passbook oauth_client config"""
from logging import getLogger
from django.apps import AppConfig
from passbook.lib.config import CONFIG
from importlib import import_module from importlib import import_module
from logging import getLogger
from django.apps import AppConfig
from passbook.lib.config import CONFIG
LOGGER = getLogger(__name__) LOGGER = getLogger(__name__)
class PassbookOAuthClientConfig(AppConfig): class PassbookOAuthClientConfig(AppConfig):

View File

@ -5,6 +5,7 @@ from django.db.models import Q
from passbook.oauth_client.models import OAuthSource, UserOAuthSourceConnection from passbook.oauth_client.models import OAuthSource, UserOAuthSourceConnection
class AuthorizedServiceBackend(ModelBackend): class AuthorizedServiceBackend(ModelBackend):
"Authentication backend for users registered with remote OAuth provider." "Authentication backend for users registered with remote OAuth provider."

View File

@ -1,7 +1,7 @@
# Generated by Django 2.1.3 on 2018-11-11 08:22 # Generated by Django 2.1.3 on 2018-11-11 14:06
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -9,8 +9,6 @@ from passbook.oauth_client.clients import get_client
class OAuthSource(Source): class OAuthSource(Source):
"""Configuration for OAuth provider.""" """Configuration for OAuth provider."""
# FIXME: Dynamically load available source_types
provider_type = models.CharField(max_length=255) provider_type = models.CharField(max_length=255)
request_token_url = models.CharField(blank=True, max_length=255) request_token_url = models.CharField(blank=True, max_length=255)
authorization_url = models.CharField(max_length=255) authorization_url = models.CharField(max_length=255)

View File

@ -6,9 +6,9 @@ from django.contrib.auth import get_user_model
from requests.exceptions import RequestException from requests.exceptions import RequestException
from passbook.oauth_client.clients import OAuth2Client from passbook.oauth_client.clients import OAuth2Client
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
from passbook.oauth_client.utils import user_get_or_create from passbook.oauth_client.utils import user_get_or_create
from passbook.oauth_client.views.core import OAuthCallback, OAuthRedirect from passbook.oauth_client.views.core import OAuthCallback, OAuthRedirect
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
LOGGER = getLogger(__name__) LOGGER = getLogger(__name__)

View File

@ -3,9 +3,9 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from passbook.oauth_client.errors import OAuthClientEmailMissingError from passbook.oauth_client.errors import OAuthClientEmailMissingError
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
from passbook.oauth_client.utils import user_get_or_create from passbook.oauth_client.utils import user_get_or_create
from passbook.oauth_client.views.core import OAuthCallback, OAuthRedirect from passbook.oauth_client.views.core import OAuthCallback, OAuthRedirect
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
@MANAGER.source(kind=RequestKind.redirect, name='facebook') @MANAGER.source(kind=RequestKind.redirect, name='facebook')

View File

@ -3,9 +3,9 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from passbook.oauth_client.errors import OAuthClientEmailMissingError from passbook.oauth_client.errors import OAuthClientEmailMissingError
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
from passbook.oauth_client.utils import user_get_or_create from passbook.oauth_client.utils import user_get_or_create
from passbook.oauth_client.views.core import OAuthCallback from passbook.oauth_client.views.core import OAuthCallback
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
@MANAGER.source(kind=RequestKind.callback, name='github') @MANAGER.source(kind=RequestKind.callback, name='github')

View File

@ -1,9 +1,9 @@
"""Google OAuth Views""" """Google OAuth Views"""
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
from passbook.oauth_client.utils import user_get_or_create from passbook.oauth_client.utils import user_get_or_create
from passbook.oauth_client.views.core import OAuthCallback, OAuthRedirect from passbook.oauth_client.views.core import OAuthCallback, OAuthRedirect
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
@MANAGER.source(kind=RequestKind.redirect, name='google') @MANAGER.source(kind=RequestKind.redirect, name='google')

View File

@ -1,6 +1,7 @@
"""Source type manager""" """Source type manager"""
from logging import getLogger
from enum import Enum from enum import Enum
from logging import getLogger
from passbook.oauth_client.views.core import OAuthCallback, OAuthRedirect from passbook.oauth_client.views.core import OAuthCallback, OAuthRedirect
LOGGER = getLogger(__name__) LOGGER = getLogger(__name__)

View File

@ -1,4 +1,3 @@
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
"""Reddit OAuth Views""" """Reddit OAuth Views"""
import json import json
from logging import getLogger from logging import getLogger
@ -8,13 +7,13 @@ from requests.auth import HTTPBasicAuth
from requests.exceptions import RequestException from requests.exceptions import RequestException
from passbook.oauth_client.clients import OAuth2Client from passbook.oauth_client.clients import OAuth2Client
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
from passbook.oauth_client.utils import user_get_or_create from passbook.oauth_client.utils import user_get_or_create
from passbook.oauth_client.views.core import OAuthCallback, OAuthRedirect from passbook.oauth_client.views.core import OAuthCallback, OAuthRedirect
LOGGER = getLogger(__name__) LOGGER = getLogger(__name__)
@MANAGER.source(kind=RequestKind.redirect, name='reddit') @MANAGER.source(kind=RequestKind.redirect, name='reddit')
class RedditOAuthRedirect(OAuthRedirect): class RedditOAuthRedirect(OAuthRedirect):
"""Reddit OAuth2 Redirect""" """Reddit OAuth2 Redirect"""

View File

@ -7,9 +7,9 @@ from django.contrib.auth import get_user_model
from requests.exceptions import RequestException from requests.exceptions import RequestException
from passbook.oauth_client.clients import OAuth2Client from passbook.oauth_client.clients import OAuth2Client
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
from passbook.oauth_client.utils import user_get_or_create from passbook.oauth_client.utils import user_get_or_create
from passbook.oauth_client.views.core import OAuthCallback from passbook.oauth_client.views.core import OAuthCallback
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
LOGGER = getLogger(__name__) LOGGER = getLogger(__name__)

View File

@ -7,9 +7,9 @@ from requests.exceptions import RequestException
from passbook.oauth_client.clients import OAuthClient from passbook.oauth_client.clients import OAuthClient
from passbook.oauth_client.errors import OAuthClientEmailMissingError from passbook.oauth_client.errors import OAuthClientEmailMissingError
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
from passbook.oauth_client.utils import user_get_or_create from passbook.oauth_client.utils import user_get_or_create
from passbook.oauth_client.views.core import OAuthCallback from passbook.oauth_client.views.core import OAuthCallback
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
LOGGER = getLogger(__name__) LOGGER = getLogger(__name__)

View File

@ -0,0 +1,3 @@
"""passbook oauth_provider Header"""
__version__ = '0.0.1-alpha'
default_app_config = 'passbook.oauth_provider.apps.PassbookOAuthProviderConfig'

View File

@ -0,0 +1,4 @@
"""passbook oauth provider Admin"""
from passbook.lib.admin import admin_autoregister
admin_autoregister('passbook_oauth_provider')

View File

@ -0,0 +1,10 @@
"""passbook auth oauth provider app config"""
from django.apps import AppConfig
class PassbookOAuthProviderConfig(AppConfig):
"""passbook auth oauth provider app config"""
name = 'passbook.oauth_provider'
label = 'passbook_oauth_provider'

View File

@ -0,0 +1,70 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-08-16 18:05+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: templates/oauth2_provider/authorize.html:18
msgid "SSO - Authorize External Source"
msgstr ""
#: templates/oauth2_provider/authorize.html:29
#, python-format
msgid ""
"\n"
" You're about to sign into %(remote)s\n"
" "
msgstr ""
#: templates/oauth2_provider/authorize.html:33
msgid "Application requires following permissions"
msgstr ""
#: templates/oauth2_provider/authorize.html:42
#, python-format
msgid ""
"\n"
" You are logged in as %(user)s. Not you?\n"
" "
msgstr ""
#: templates/oauth2_provider/authorize.html:45
msgid "Logout"
msgstr ""
#: templates/oauth2_provider/authorize.html:49
msgid "Continue"
msgstr ""
#: templates/oauth2_provider/authorize.html:52
msgid "Cancel"
msgstr ""
#: templates/oauth2_provider/authorize.html:59
#, python-format
msgid "Error: %(err)s"
msgstr ""
#: views/oauth2.py:49
#, python-format
msgid "You authenticated %s (via OAuth) (skipped Authz)"
msgstr ""
#: views/oauth2.py:62
#, python-format
msgid "You authenticated %s (via OAuth)"
msgstr ""

View File

@ -0,0 +1,69 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-08-20 10:47+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: templates/oauth2_provider/authorize.html:18
msgid "SSO - Authorize External Source"
msgstr ""
#: templates/oauth2_provider/authorize.html:29
#, python-format
msgid ""
"\n"
" You're about to sign into %(remote)s\n"
" "
msgstr ""
#: templates/oauth2_provider/authorize.html:33
msgid "Application requires following permissions"
msgstr ""
#: templates/oauth2_provider/authorize.html:42
#, python-format
msgid ""
"\n"
" You are logged in as %(user)s. Not you?\n"
" "
msgstr ""
#: templates/oauth2_provider/authorize.html:45
msgid "Logout"
msgstr ""
#: templates/oauth2_provider/authorize.html:49
msgid "Continue"
msgstr ""
#: templates/oauth2_provider/authorize.html:52
msgid "Cancel"
msgstr ""
#: templates/oauth2_provider/authorize.html:59
#, python-format
msgid "Error: %(err)s"
msgstr ""
#: views/oauth2.py:49
#, python-format
msgid "You authenticated %s (via OAuth) (skipped Authz)"
msgstr ""
#: views/oauth2.py:62
#, python-format
msgid "You authenticated %s (via OAuth)"
msgstr ""

View File

@ -0,0 +1,70 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-08-16 18:05+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: templates/oauth2_provider/authorize.html:18
msgid "SSO - Authorize External Source"
msgstr ""
#: templates/oauth2_provider/authorize.html:29
#, python-format
msgid ""
"\n"
" You're about to sign into %(remote)s\n"
" "
msgstr ""
#: templates/oauth2_provider/authorize.html:33
msgid "Application requires following permissions"
msgstr ""
#: templates/oauth2_provider/authorize.html:42
#, python-format
msgid ""
"\n"
" You are logged in as %(user)s. Not you?\n"
" "
msgstr ""
#: templates/oauth2_provider/authorize.html:45
msgid "Logout"
msgstr ""
#: templates/oauth2_provider/authorize.html:49
msgid "Continue"
msgstr ""
#: templates/oauth2_provider/authorize.html:52
msgid "Cancel"
msgstr ""
#: templates/oauth2_provider/authorize.html:59
#, python-format
msgid "Error: %(err)s"
msgstr ""
#: views/oauth2.py:49
#, python-format
msgid "You authenticated %s (via OAuth) (skipped Authz)"
msgstr ""
#: views/oauth2.py:62
#, python-format
msgid "You authenticated %s (via OAuth)"
msgstr ""

View File

@ -0,0 +1,70 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-08-16 18:05+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: templates/oauth2_provider/authorize.html:18
msgid "SSO - Authorize External Source"
msgstr ""
#: templates/oauth2_provider/authorize.html:29
#, python-format
msgid ""
"\n"
" You're about to sign into %(remote)s\n"
" "
msgstr ""
#: templates/oauth2_provider/authorize.html:33
msgid "Application requires following permissions"
msgstr ""
#: templates/oauth2_provider/authorize.html:42
#, python-format
msgid ""
"\n"
" You are logged in as %(user)s. Not you?\n"
" "
msgstr ""
#: templates/oauth2_provider/authorize.html:45
msgid "Logout"
msgstr ""
#: templates/oauth2_provider/authorize.html:49
msgid "Continue"
msgstr ""
#: templates/oauth2_provider/authorize.html:52
msgid "Cancel"
msgstr ""
#: templates/oauth2_provider/authorize.html:59
#, python-format
msgid "Error: %(err)s"
msgstr ""
#: views/oauth2.py:49
#, python-format
msgid "You authenticated %s (via OAuth) (skipped Authz)"
msgstr ""
#: views/oauth2.py:62
#, python-format
msgid "You authenticated %s (via OAuth)"
msgstr ""

View File

@ -0,0 +1,29 @@
# Generated by Django 2.1.3 on 2018-11-14 18:35
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
('passbook_core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='OAuth2Application',
fields=[
('application_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Application')),
('oauth2', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
],
options={
'abstract': False,
},
bases=('passbook_core.application',),
),
]

View File

@ -0,0 +1,12 @@
"""Oauth2 provider product extension"""
from django.db import models
from oauth2_provider.models import Application as _OAuth2Application
from passbook.core.models import Application
class OAuth2Application(Application):
"""Associate an OAuth2 Application with a Product"""
oauth2 = models.ForeignKey(_OAuth2Application, on_delete=models.CASCADE)

View File

@ -0,0 +1,2 @@
django-oauth-toolkit
django-cors-middleware

View File

@ -0,0 +1,16 @@
"""passbook OAuth_Provider"""
CORS_ORIGIN_ALLOW_ALL = True
REQUEST_APPROVAL_PROMPT = 'auto'
MIDDLEWARE = [
'oauth2_provider.middleware.OAuth2TokenMiddleware',
'corsheaders.middleware.CorsMiddleware',
]
INSTALLED_APPS = [
'oauth2_provider',
'corsheaders',
]
AUTHENTICATION_BACKENDS = [
'oauth2_provider.backends.OAuth2Backend',
]

View File

@ -0,0 +1,12 @@
"""passbook oauth_provider urls"""
from django.urls import include, path
from passbook.oauth_provider.views import oauth2
urlpatterns = [
# Custom OAuth 2 Authorize View
# path('authorize/', oauth2.PassbookAuthorizationView.as_view(), name="oauth2-authorize"),
# OAuth API
path('oauth2/', include('oauth2_provider.urls', namespace='oauth2_provider')),
]

View File

@ -0,0 +1,58 @@
"""passbook OAuth2 Views"""
from logging import getLogger
from django.contrib import messages
from django.http import Http404, HttpResponseRedirect
from django.utils.translation import ugettext as _
from oauth2_provider.models import get_application_model
from oauth2_provider.views.base import AuthorizationView
# from passbook.core.models import Event, UserAcquirableRelationship
LOGGER = getLogger(__name__)
class PassbookAuthorizationView(AuthorizationView):
"""Custom OAuth2 Authorization View which checks for invite_only products"""
def get(self, request, *args, **kwargs):
"""Check if request.user has a relationship with product"""
full_res = super().get(request, *args, **kwargs)
# If application cannot be found, oauth2_data is {}
if self.oauth2_data == {}:
return full_res
# self.oauth2_data['application'] should be set, if not an error occured
# if 'application' in self.oauth2_data:
# app = self.oauth2_data['application']
# if app.productextensionoauth2_set.exists() and \
# app.productextensionoauth2_set.first().product_set.exists():
# # Only check if there is a connection from OAuth2 Application to product
# product = app.productextensionoauth2_set.first().product_set.first()
# relationship = UserAcquirableRelationship.objects.filter(user=request.user,
# model=product)
# # Product is invite_only = True and no relation with user exists
# if product.invite_only and not relationship.exists():
# LOGGER.warning("User '%s' has no invitation to '%s'", request.user, product)
# messages.error(request, "You have no access to '%s'" % product.name)
# raise Http404
# if isinstance(full_res, HttpResponseRedirect):
# # Application has skip authorization on
# Event.create(
# user=request.user,
# message=_('You authenticated %s (via OAuth) (skipped Authz)' % app.name),
# request=request,
# current=False,
# hidden=True)
return full_res
def post(self, request, *args, **kwargs):
"""Add event on confirmation"""
app = get_application_model().objects.get(client_id=request.GET["client_id"])
# Event.create(
# user=request.user,
# message=_('You authenticated %s (via OAuth)' % app.name),
# request=request,
# current=False,
# hidden=True)
return super().post(request, *args, **kwargs)

View File

@ -0,0 +1,3 @@
"""passbook saml_idp Header"""
__version__ = '0.0.1-alpha'
default_app_config = 'passbook.saml_idp.apps.PassbookSAMLIDPConfig'

View File

@ -0,0 +1,5 @@
"""SAML IDP Admin"""
from passbook.lib.admin import admin_autoregister
admin_autoregister('passbook_saml_idp')

11
passbook/saml_idp/apps.py Normal file
View File

@ -0,0 +1,11 @@
"""passbook mod saml_idp app config"""
from django.apps.config import AppConfig
class PassbookSAMLIDPConfig(AppConfig):
"""passbook saml_idp app config"""
name = 'passbook.saml_idp'
label = 'passbook_saml_idp'
verbose_name = 'passbook SAML IDP'

313
passbook/saml_idp/base.py Normal file
View File

@ -0,0 +1,313 @@
"""Basic SAML Processor"""
import time
import uuid
from logging import getLogger
from bs4 import BeautifulSoup
# from passbook.core.models import Setting
from passbook.saml_idp import codex, exceptions, xml_render
MINUTES = 60
HOURS = 60 * MINUTES
def get_random_id():
"""Random hex id"""
# It is very important that these random IDs NOT start with a number.
random_id = '_' + uuid.uuid4().hex
return random_id
def get_time_string(delta=0):
"""Get Data formatted in SAML format"""
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() + delta))
# Design note: I've tried to make this easy to sub-class and override
# just the bits you need to override. I've made use of object properties,
# so that your sub-classes have access to all information: use wisely.
# Formatting note: These methods are alphabetized.
# pylint: disable=too-many-instance-attributes
class Processor:
"""Base SAML 2.0 AuthnRequest to Response Processor.
Sub-classes should provide Service Provider-specific functionality."""
_audience = ''
_assertion_params = None
_assertion_xml = None
_assertion_id = None
_django_request = None
_relay_state = None
_request = None
_request_id = None
_request_xml = None
_request_params = None
_response_id = None
_response_xml = None
_response_params = None
_saml_request = None
_saml_response = None
_session_index = None
_subject = None
_subject_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:email'
_system_params = {
'ISSUER': Setting.get('issuer'),
}
@property
def dotted_path(self):
"""Return a dotted path to this class"""
return '{module}.{class_name}'.format(
module=self.__module__,
class_name=self.__class__.__name__)
def __init__(self, remote):
self.name = remote.name
self._remote = remote
self._logger = getLogger(__name__)
self._logger.info('processor configured')
def _build_assertion(self):
"""Builds _assertion_params."""
self._determine_assertion_id()
self._determine_audience()
self._determine_subject()
self._determine_session_index()
self._assertion_params = {
'ASSERTION_ID': self._assertion_id,
'ASSERTION_SIGNATURE': '', # it's unsigned
'AUDIENCE': self._audience,
'AUTH_INSTANT': get_time_string(),
'ISSUE_INSTANT': get_time_string(),
'NOT_BEFORE': get_time_string(-1 * HOURS), # TODO: Make these settings.
'NOT_ON_OR_AFTER': get_time_string(int(Setting.get('assertion_valid_for')) * MINUTES),
'SESSION_INDEX': self._session_index,
'SESSION_NOT_ON_OR_AFTER': get_time_string(8 * HOURS),
'SP_NAME_QUALIFIER': self._audience,
'SUBJECT': self._subject,
'SUBJECT_FORMAT': self._subject_format,
}
self._assertion_params.update(self._system_params)
self._assertion_params.update(self._request_params)
def _build_response(self):
"""Builds _response_params."""
self._determine_response_id()
self._response_params = {
'ASSERTION': self._assertion_xml,
'ISSUE_INSTANT': get_time_string(),
'RESPONSE_ID': self._response_id,
'RESPONSE_SIGNATURE': '', # initially unsigned
}
self._response_params.update(self._system_params)
self._response_params.update(self._request_params)
def _decode_request(self):
"""Decodes _request_xml from _saml_request."""
self._request_xml = codex.decode_base64_and_inflate(self._saml_request).decode('utf-8')
self._logger.debug('SAML request decoded')
def _determine_assertion_id(self):
"""Determines the _assertion_id."""
self._assertion_id = get_random_id()
def _determine_audience(self):
"""Determines the _audience."""
self._audience = self._request_params.get('DESTINATION', None)
if not self._audience:
self._audience = self._request_params.get('PROVIDER_NAME', None)
self._logger.info('determined audience')
def _determine_response_id(self):
"""Determines _response_id."""
self._response_id = get_random_id()
def _determine_session_index(self):
self._session_index = self._django_request.session.session_key
def _determine_subject(self):
"""Determines _subject and _subject_type for Assertion Subject."""
self._subject = self._django_request.user.email
def _encode_response(self):
"""Encodes _response_xml to _encoded_xml."""
self._saml_response = codex.nice64(str.encode(self._response_xml))
def _extract_saml_request(self):
"""Retrieves the _saml_request AuthnRequest from the _django_request."""
self._saml_request = self._django_request.session['SAMLRequest']
self._relay_state = self._django_request.session['RelayState']
def _format_assertion(self):
"""Formats _assertion_params as _assertion_xml."""
self._assertion_params['ATTRIBUTES'] = [
{
'FriendlyName': 'eduPersonPrincipalName',
'Name': 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6',
'Value': self._django_request.user.email,
},
{
'FriendlyName': 'cn',
'Name': 'urn:oid:2.5.4.3',
'Value': self._django_request.user.first_name,
},
{
'FriendlyName': 'mail',
'Name': 'urn:oid:0.9.2342.19200300.100.1.3',
'Value': self._django_request.user.email,
},
{
'FriendlyName': 'displayName',
'Name': 'urn:oid:2.16.840.1.113730.3.1.241',
'Value': self._django_request.user.username,
},
]
self._assertion_xml = xml_render.get_assertion_xml(
'saml/xml/assertions/generic.xml', self._assertion_params, signed=True)
def _format_response(self):
"""Formats _response_params as _response_xml."""
sign_it = Setting.get_bool('signing')
assertion_id = self._assertion_params['ASSERTION_ID']
self._response_xml = xml_render.get_response_xml(self._response_params,
signed=sign_it,
assertion_id=assertion_id)
def _get_django_response_params(self):
"""Returns a dictionary of parameters for the response template."""
return {
'acs_url': self._request_params['ACS_URL'],
'saml_response': self._saml_response,
'relay_state': self._relay_state,
'autosubmit': Setting.get('autosubmit'),
}
def _parse_request(self):
"""Parses various parameters from _request_xml into _request_params."""
# Minimal test to verify that it's not binarily encoded still:
if not str(self._request_xml.strip()).startswith('<'):
raise Exception('RequestXML is not valid XML; '
'it may need to be decoded or decompressed.')
soup = BeautifulSoup(self._request_xml, features="xml")
request = soup.findAll()[0]
params = {}
params['ACS_URL'] = request['AssertionConsumerServiceURL']
params['REQUEST_ID'] = request['ID']
params['DESTINATION'] = request.get('Destination', '')
params['PROVIDER_NAME'] = request.get('ProviderName', '')
self._request_params = params
def _reset(self, django_request, sp_config=None):
"""Initialize (and reset) object properties, so we don't risk carrying
over anything from the last authentication.
If provided, use sp_config throughout; otherwise, it will be set in
_validate_request(). """
self._assertion_params = sp_config
self._assertion_xml = sp_config
self._assertion_id = sp_config
self._django_request = django_request
self._relay_state = sp_config
self._request = sp_config
self._request_id = sp_config
self._request_xml = sp_config
self._request_params = sp_config
self._response_id = sp_config
self._response_xml = sp_config
self._response_params = sp_config
self._saml_request = sp_config
self._saml_response = sp_config
self._session_index = sp_config
self._subject = sp_config
self._subject_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:email'
self._system_params = {
'ISSUER': Setting.get('issuer'),
}
def _validate_request(self):
"""
Validates the SAML request against the SP configuration of this
processor. Sub-classes should override this and raise a
`CannotHandleAssertion` exception if the validation fails.
Raises:
CannotHandleAssertion: if the ACS URL specified in the SAML request
doesn't match the one specified in the processor config.
"""
request_acs_url = self._request_params['ACS_URL']
if self._remote.acs_url != request_acs_url:
msg = ("couldn't find ACS url '{}' in SAML2IDP_REMOTES "
"setting.".format(request_acs_url))
self._logger.info(msg)
raise exceptions.CannotHandleAssertion(msg)
def _validate_user(self):
"""Validates the User. Sub-classes should override this and
throw an CannotHandleAssertion Exception if the validation does not succeed."""
pass
def can_handle(self, request):
"""Returns true if this processor can handle this request."""
self._reset(request)
# Read the request.
try:
self._extract_saml_request()
except Exception as exc:
msg = "can't find SAML request in user session: %s" % exc
self._logger.info(msg)
raise exceptions.CannotHandleAssertion(msg)
try:
self._decode_request()
except Exception as exc:
msg = "can't decode SAML request: %s" % exc
self._logger.info(msg)
raise exceptions.CannotHandleAssertion(msg)
try:
self._parse_request()
except Exception as exc:
msg = "can't parse SAML request: %s" % exc
self._logger.info(msg)
raise exceptions.CannotHandleAssertion(msg)
self._validate_request()
return True
def generate_response(self):
"""Processes request and returns template variables suitable for a response."""
# Build the assertion and response.
self._validate_user()
self._build_assertion()
self._format_assertion()
self._build_response()
self._format_response()
self._encode_response()
# Return proper template params.
return self._get_django_response_params()
def init_deep_link(self, request, sp_config, url):
"""Initialize this Processor to make an IdP-initiated call to the SP's
deep-linked URL."""
self._reset(request, sp_config)
acs_url = self._remote['acs_url']
# NOTE: The following request params are made up. Some are blank,
# because they comes over in the AuthnRequest, but we don't have an
# AuthnRequest in this case:
# - Destination: Should be this IdP's SSO endpoint URL. Not used in the response?
# - ProviderName: According to the spec, this is optional.
self._request_params = {
'ACS_URL': acs_url,
'DESTINATION': '',
'PROVIDER_NAME': '',
}
self._relay_state = url

View File

@ -0,0 +1,22 @@
"""Wrappers to de/encode and de/inflate strings"""
import base64
import zlib
def decode_base64_and_inflate(b64string):
"""Base64 decode and ZLib decompress b64string"""
decoded_data = base64.b64decode(b64string)
return zlib.decompress(decoded_data, -15)
def deflate_and_base64_encode(string_val):
"""Base64 and ZLib Compress b64string"""
zlibbed_str = zlib.compress(string_val)
compressed_string = zlibbed_str[2:-4]
return base64.b64encode(compressed_string)
def nice64(src):
""" Returns src base64-encoded and formatted nicely for our XML. """
return base64.b64encode(src).decode('utf-8').replace('\n', '')

View File

@ -0,0 +1,11 @@
"""passbook SAML IDP Exceptions"""
class CannotHandleAssertion(Exception):
"""This processor does not handle this assertion."""
pass
class UserNotAuthorized(Exception):
"""User not authorized for SAML 2.0 authentication."""
pass

View File

View File

@ -0,0 +1,23 @@
"""passbook saml_idp Models"""
from django.db import models
from passbook.core.models import Application
from passbook.lib.utils.reflection import class_to_path
from passbook.saml_idp.base import Processor
class SAMLRemote(Application):
"""Model to save information about a Remote SAML Endpoint"""
acs_url = models.URLField()
processor_path = models.CharField(max_length=255, choices=[])
skip_authorization = models.BooleanField(default=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
processors = [(class_to_path(x), x.__name__) for x in Processor.__subclasses__()]
self._meta.get_field('processor_path').choices = processors
def __str__(self):
return "SAMLRemote %s (processor=%s)" % (self.name, self.processor_path)

View File

View File

@ -0,0 +1,32 @@
"""
Demo Processor
"""
from supervisr.mod.auth.saml.idp.base import Processor
from supervisr.mod.auth.saml.idp.xml_render import get_assertion_xml
class DemoProcessor(Processor):
"""
Demo Response Handler Processor for testing against django-saml2-sp.
"""
def _format_assertion(self):
# NOTE: This uses the SalesForce assertion for the demo.
self._assertion_xml = get_assertion_xml(
'saml/xml/assertions/salesforce.xml', self._assertion_params, signed=True)
class DemoAttributeProcessor(Processor):
"""
Demo Response Handler Processor for testing against django-saml2-sp;
Adds SAML attributes to the assertion.
"""
def _format_assertion(self):
# NOTE: This uses the SalesForce assertion for the demo.
self._assertion_params['ATTRIBUTES'] = {
'foo': 'bar',
}
self._assertion_xml = get_assertion_xml(
'saml/xml/assertions/salesforce.xml', self._assertion_params, signed=True)

View File

@ -0,0 +1,12 @@
"""
Generic Processor
"""
from supervisr.mod.auth.saml.idp.base import Processor
class GenericProcessor(Processor):
"""
Generic Response Handler Processor for testing against django-saml2-sp.
"""
pass

View File

@ -0,0 +1,16 @@
"""
GitLab Processor
"""
from supervisr.mod.auth.saml.idp.base import Processor
class GitLabProcessor(Processor):
"""
GitLab Response Handler Processor for testing against django-saml2-sp.
"""
def _determine_audience(self):
# Nextcloud expects an audience in this format
# https://<host>
self._audience = self._remote.acs_url.replace('/users/auth/saml/callback', '')

View File

@ -0,0 +1,15 @@
"""
NextCloud Processor
"""
from supervisr.mod.auth.saml.idp.base import Processor
class NextCloudProcessor(Processor):
"""
Nextcloud SAML 2.0 AuthnRequest to Response Handler Processor.
"""
def _determine_audience(self):
# Nextcloud expects an audience in this format
# https://<host>/index.php/apps/user_saml/saml/metadata
self._audience = self._remote.acs_url.replace('acs', 'metadata')

View File

@ -0,0 +1,19 @@
"""
Salesforce Processor
"""
from supervisr.mod.auth.saml.idp.base import Processor
from supervisr.mod.auth.saml.idp.xml_render import get_assertion_xml
class SalesForceProcessor(Processor):
"""
SalesForce.com-specific SAML 2.0 AuthnRequest to Response Handler Processor.
"""
def _determine_audience(self):
self._audience = 'IAMShowcase'
def _format_assertion(self):
self._assertion_xml = get_assertion_xml(
'saml/xml/assertions/salesforce.xml', self._assertion_params, signed=True)

View File

@ -0,0 +1,17 @@
"""
Shib Processor
"""
from supervisr.mod.auth.saml.idp.base import Processor
class ShibProcessor(Processor):
"""
Shib-specific Processor
"""
def _determine_audience(self):
"""
Determines the _audience.
"""
self._audience = "https://sp.testshib.org/shibboleth-sp"

View File

@ -0,0 +1,17 @@
"""
WordpressOrange Processor
"""
from supervisr.mod.auth.saml.idp.base import Processor
class WordpressOrangeProcessor(Processor):
"""
WordpressOrange Response Handler Processor for testing against django-saml2-sp.
"""
def _determine_audience(self):
# Orange expects an audience in this format
# https://<host>/wp-content/plugins/miniorange-saml-20-single-sign-on/
self._audience = self._remote.acs_url + \
'wp-content/plugins/miniorange-saml-20-single-sign-on/'

View File

@ -0,0 +1,28 @@
"""Registers and loads Processor classes from settings."""
from logging import getLogger
from passbook.lib.utils.reflection import path_to_class
from passbook.saml_idp.exceptions import CannotHandleAssertion
from passbook.saml_idp.models import SAMLRemote
LOGGER = getLogger(__name__)
def get_processor(remote):
"""Get an instance of the processor with config."""
proc = path_to_class(remote.processor_path)
return proc(remote)
def find_processor(request):
"""Returns the Processor instance that is willing to handle this request."""
for remote in SAMLRemote.objects.all():
proc = get_processor(remote)
try:
if proc.can_handle(request):
return proc, remote
except CannotHandleAssertion as exc:
# Log these, but keep looking.
LOGGER.debug('%s %s', proc, exc)
raise CannotHandleAssertion('No Processors to handle this request.')

View File

@ -0,0 +1,4 @@
beautifulsoup4>=4.6.0
lxml>=3.8.0
signxml
defusedxml

View File

@ -0,0 +1,57 @@
"""SAML2 IDP Default settings"""
SAML2IDP_CONFIG = {
# Default metadata to configure this local IdP.
'autosubmit': True,
'certificate_data': """-----BEGIN CERTIFICATE-----
MIIDrTCCApWgAwIBAgIJAMyu7G6V0HCtMA0GCSqGSIb3DQEBCwUAMGwxCzAJBgNV
BAYTAkRFMQswCQYDVQQIDAJCVzEWMBQGA1UEBwwNV2VpbCBhbSBSaGVpbjETMBEG
A1UECgwKQmVyeUp1Lm9yZzEjMCEGA1UEAwwaU3VwZXJ2aXNyIFNBTUwgSURQIERl
ZmF1bHQwIBcNMTcwNjMwMTQzNjU2WhgPNDAxNjAzMDIxNDM2NTZaMGwxCzAJBgNV
BAYTAkRFMQswCQYDVQQIDAJCVzEWMBQGA1UEBwwNV2VpbCBhbSBSaGVpbjETMBEG
A1UECgwKQmVyeUp1Lm9yZzEjMCEGA1UEAwwaU3VwZXJ2aXNyIFNBTUwgSURQIERl
ZmF1bHQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDh+wp/kf2mSJd9
s562gH6NUAZEFpMqeicKJLLrbt0qmovEej6HIKNTTrnQUyaq5L5u6FBALwrURpx7
NztzwcNehfmKdl0n1AsHWaWuuaRSPwxv9F/YCEeq15KLC686DN0lG2MDaeFxF1xe
23FnZUQ06/G7lSGO4tZUEvEFaYX48M1txydmeLxJHyQPfsADK9ozK6h9+daDD/uJ
OSrN4kgh19hMIDg1BPJ0JldK3ohjgFNhQ+KZ9CvgfU9kVzHZ6ZbsKyG20HFCTu8D
lV5QFi+CcTj9BgkXNE1pVc15P6Ef97dg3DYgLIZNBK8gWweQzMvtAJeqd9Oj9dGY
PzONsHY5AgMBAAGjUDBOMB0GA1UdDgQWBBRgrJg/30Y1O4bgan+YJ0D0rf5s0DAf
BgNVHSMEGDAWgBRgrJg/30Y1O4bgan+YJ0D0rf5s0DAMBgNVHRMEBTADAQH/MA0G
CSqGSIb3DQEBCwUAA4IBAQBaITBSa75Y1dlDdvIp7/NgidRYgOx6xrVC5eYqf0X7
GNBidh3PSqBeiuK9ARtzmoWKS/G5Ufr6dvS7SglcEIqhba33iIaRtB5P14yYb8j1
lXKTy/plv+Z2DXeqcCVlFJqc9wSZx2Shkump5ctvkPIV5qW29fQA3IeM+bdNgqVr
8mEagDJEnFIpbCkkKTFNIrWR8f72SXzc0jxPi89oFlMvINc+ogaFSxwbyPMIMoaI
IPMtp3THfTObYBoLNeeWMug/ynKMcUNs4pzh97RNacAxMYSb/3rbblrnq0CYDcmG
RHlwc9dbwx1rVaCt+dYznAoD8rvZw8iCaS2m4b75uzsn
-----END CERTIFICATE-----""",
'private_key_data': """-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEA4fsKf5H9pkiXfbOetoB+jVAGRBaTKnonCiSy627dKpqLxHo+
hyCjU0650FMmquS+buhQQC8K1Eacezc7c8HDXoX5inZdJ9QLB1mlrrmkUj8Mb/Rf
2AhHqteSiwuvOgzdJRtjA2nhcRdcXttxZ2VENOvxu5UhjuLWVBLxBWmF+PDNbccn
Zni8SR8kD37AAyvaMyuoffnWgw/7iTkqzeJIIdfYTCA4NQTydCZXSt6IY4BTYUPi
mfQr4H1PZFcx2emW7CshttBxQk7vA5VeUBYvgnE4/QYJFzRNaVXNeT+hH/e3YNw2
ICyGTQSvIFsHkMzL7QCXqnfTo/XRmD8zjbB2OQIDAQABAoIBAQDUZ8JWZkKkKVc7
L7nekKhi6vT4yr9JDcfkINqLsIjxopH8+2oKWQMrKrQ8u+t8dcUJOhM0QQNMw5IR
vriC9X1NO2ByZQ7qgMRdBEZXFOb+54QpNulfhWjXjAiR6Umqpqy2VCec7ciZI/wO
rPTK2sRheeSdDG+eflg2bhddnvHuKaSD0N27guhRYDg8e0NpqohuWHftzC0Z3OqQ
2nTVYSNFev8V0cNN8ESK+r/S1MG0BlxuhPzdp3SolGdYvAQNp4RizZslnnYuBmMf
SMoZY689v/v622xrQ0pHiPU72lgcSXRzlFD6p4+ecxHvhtZiPVEIUtCLXdmaOs1b
6mlKZs6BAoGBAPjPdLVe9gSUB9s91RIpY7JsPyjABzH0WgLFAMat2VlZQM0b1o2y
U65kd8HY/xxzDRxzsTuE+7fusipk5zlwfmyPhxEbwHyjT6xFUneBiHamKOR5F6Xk
2HdOc4swMXitAFsHDl85ys+ovHV50nb6TilEW2vAIj7J178NdMGRbE2LAoGBAOiC
tHNOyfuUVzYU34oOhQ4B1VVLB60LJSFnPdHoFss/nt73kLWuw0Z5iuX6f3PhybiA
6qSLT53EzmcrtUUa6H9MNW2d4bGLMkGn3rku6XKBH4d4h7D3YVUQCCx0nDz30FNz
90/9J0oZbrksnUlE5EpU+vpRmvriz1AFTljDrgvLAoGBAPiLbD990+5w3YRCOSWC
WQg0H8eaQ9XADWZ02zidE+CwSw5Zf7Nebz9nN0ZaeUU3HOLOIz6cskNj23CECYMU
gAX8PmV1vowDK6SgPygIKoSzqWfKGzhp6V8M7FkfVFwDHbbQzqeLeLCGE3SatAaM
NiX9FgIGFW95e95rF7YBihnPAoGAAx8+LQ4xyB8FzMQa/E+VmcqMgsivIbO0m+42
9kqXg8Mm7veECex+0sNvCgeDDptJiiCxBeSY/RVXcCs2E+d4l7z+OqqUDT5BPoBy
jSoEGHWDZt5HdCjeNbYxZedq8aaiNXypJXnQvT36LqJaulEif50Egbf2zMee4QQx
OR/nhmECgYEAwc7/woIMJFOSfo3IgsYU8a7KKQ0w2JSvXMND9IkMjo/Oc8mT08Z1
hMv77bCX4zZr162Wg02BgA5rKPHu56ofjOBeQvabfmzB0d+H/mxv/V7PC50QBqLd
zcepulF4OHOf+b2vKPmgN/HoQQyISw6l7SwuOH0gQI+SOxyBNuIIqN0=
-----END RSA PRIVATE KEY-----""",
'issuer': 'http://localhost:8000',
'signing': True,
}

View File

@ -0,0 +1,8 @@
{% extends "core/base.html" %}
{% comment %}
This is a placeholder template. You can override this saml2idp/base.html
template to make all the saml2idp templates fit better into your site's
look-and-feel. That may be easier than overriding all the saml2idp templates
individually.
{% endcomment %}

View File

@ -0,0 +1,5 @@
{% extends "saml/idp/base.html" %}
{% load i18n %}
{% block content %}
{% trans "You have logged in, but your user account is not enabled for SAML 2.0." %}
{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends "saml/idp/base.html" %}
{% load i18n %}
{% block content %}
{% trans "You have successfully logged out of the Identity Provider." %}
{% endblock %}

View File

@ -0,0 +1,47 @@
{% extends "core/skel.html" %}
{% load supervisr_utils %}
{% load i18n %}
{% block title %}
{% title 'SSO - Authorize External Source' %}
{% endblock %}
{% block body %}
<div class="login-wrapper">
<form class="login" method="post">
{% csrf_token %}
<input type="hidden" name="ACSUrl" value="{{ acs_url }}">
<input type="hidden" name="RelayState" value="{{ relay_state }}" />
<input type="hidden" name="SAMLResponse" value="{{ saml_response }}" />
<label class="title">
<clr-icon shape="supervisr" class="is-info" size="48"></clr-icon>
{% supervisr_setting 'branding' %}
</label>
<label class="subtitle">
{% trans 'SSO - Authorize External Source' %}
</label>
<div class="login-group">
<p class="subtitle">
{% blocktrans with remote=remote.name %}
You're about to sign into {{ remote }}
{% endblocktrans %}
</p>
<p>
{% blocktrans with user=user %}
You are logged in as {{ user }}. Not you?
{% endblocktrans %}
<a href="{% url 'account-logout' %}">{% trans 'Logout' %}</a>
</p>
<div class="row">
<div class="col-md-6">
<input class="btn btn-success btn-block" type="submit" value="{% trans "Continue" %}" />
</div>
<div class="col-md-6">
<a href="{% url 'common-index' %}" class="btn btn-outline btn-block">{% trans "Cancel" %}</a>
</div>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,47 @@
{% extends "_admin/module_default.html" %}
{% load i18n %}
{% load supervisr_utils %}
{% block title %}
{% title "Overview" %}
{% endblock %}
{% block module_content %}
<h2><clr-icon shape="application" size="32"></clr-icon>{% trans 'SAML2 IDP' %}</h2>
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h2><clr-icon shape="settings" size="32"></clr-icon>{% trans 'Settings' %}</h2>
</div>
<form role="form" method="POST">
<div class="card-block">
{% include 'blocks/form.html' with form=form %}
</div>
<div class="card-footer">
<button type="submit" class="btn btn-primary">{% trans 'Update' %}</button>
</div>
</form>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h2><clr-icon shape="bank" size="32"></clr-icon>{% trans 'Metadata' %}</h2>
</div>
<div class="card-block">
<p>{% trans 'Cert Fingerprint (SHA1):' %} <pre>{{ fingerprint }}</pre></p>
<section class="form-block">
<pre lang="xml" >{{ metadata }}</pre>
</section>
</div>
<div class="card-footer">
<a href="{% url 'supervisr_mod_auth_saml_idp:metadata_xml' %}" class="btn btn-primary"><clr-icon shape="download"></clr-icon>{% trans 'Download Metadata' %}</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,19 @@
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="{{ ASSERTION_ID }}"
IssueInstant="{{ ISSUE_INSTANT }}"
Version="2.0">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer>
{% include 'saml/xml/signature.xml' %}
{{ SUBJECT_STATEMENT }}
<saml:Conditions NotBefore="2014-07-17T01:01:18Z" NotOnOrAfter="2024-01-18T06:21:48Z">
<saml:AudienceRestriction>
<saml:Audience>{{ AUDIENCE }}</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="2014-07-17T01:01:48Z" SessionNotOnOrAfter="2024-07-17T09:01:48Z" SessionIndex="_be9967abd904ddcae3c0eb4189adbe3f71e327cf93">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
{{ ATTRIBUTE_STATEMENT }}
</saml:Assertion>

View File

@ -0,0 +1,15 @@
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="{{ ASSERTION_ID }}"
IssueInstant="{{ ISSUE_INSTANT }}"
Version="2.0">
<saml:Issuer>{{ ISSUER }}</saml:Issuer>
{% include 'saml/xml/signature.xml' %}
{% include 'saml/xml/subject.xml' %}
<saml:Conditions NotBefore="{{ NOT_BEFORE }}" NotOnOrAfter="{{ NOT_ON_OR_AFTER }}" />
<saml:AuthnStatement AuthnInstant="{{ AUTH_INSTANT }}">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
{{ ATTRIBUTE_STATEMENT }}
</saml:Assertion>

View File

@ -0,0 +1,19 @@
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="{{ ASSERTION_ID }}"
IssueInstant="{{ ISSUE_INSTANT }}"
Version="2.0">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer>
{{ ASSERTION_SIGNATURE|safe }}
{% include 'saml/xml/subject.xml' %}
<saml:Conditions NotBefore="{{ NOT_BEFORE }}" NotOnOrAfter="{{ NOT_ON_OR_AFTER }}">
<saml:AudienceRestriction>
<saml:Audience>{{ AUDIENCE }}</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="{{ AUTH_INSTANT }}">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
{{ ATTRIBUTE_STATEMENT|safe }}
</saml:Assertion>

View File

@ -0,0 +1,7 @@
<saml:AttributeStatement>
{% for attr in attributes %}
<saml:Attribute FriendlyName="{{ attr.FriendlyName }}" Name="{{ attr.Name }}" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">{{ attr.Value }}</saml:AttributeValue>
</saml:Attribute>
{% endfor %}
</saml:AttributeStatement>

View File

@ -0,0 +1,40 @@
<?xml version="1.0"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="{{ entity_id }}">
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>{{ cert_public_key }}</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:KeyDescriptor use="encryption">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>{{ cert_public_key }}</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ slo_url }}"/>
<md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:email</md:NameIDFormat>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ sso_url }}"/>
</md:IDPSSODescriptor>
{% comment %}
<!-- #TODO: Add support for optional Organization section -->
{# if org #}
<md:Organization>
<md:OrganizationName xml:lang="en">{{ org.name }}</md:OrganizationName>
<md:OrganizationDisplayName xml:lang="en">{{ org.display_name }}</md:OrganizationDisplayName>
<md:OrganizationURL xml:lang="en">{{ org.url }}</md:OrganizationURL>
</md:Organization>
{# endif #}
<!-- #TODO: Add support for optional ContactPerson section(s) -->
{# for contact in contacts #}
<md:ContactPerson contactType="{{ contact.type }}">
<md:GivenName>{{ contact.given_name }}</md:GivenName>
<md:SurName>{{ contact.sur_name }}</md:SurName>
<md:EmailAddress>{{ contact.email }}</md:EmailAddress>
</md:ContactPerson>
{# endfor #}
{% endcomment %}
</md:EntityDescriptor>

View File

@ -0,0 +1,13 @@
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
Destination="{{ ACS_URL }}"
ID="{{ RESPONSE_ID }}"
{{ IN_RESPONSE_TO|safe }}
IssueInstant="{{ ISSUE_INSTANT }}"
Version="2.0">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer>
{{ ASSERTION_SIGNATURE }}
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
</samlp:Status>
{{ ASSERTION }}
</samlp:Response>

View File

@ -0,0 +1 @@
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="placeholder"></ds:Signature>

View File

@ -0,0 +1,8 @@
<saml:Subject>
<saml:NameID Format="{{ SUBJECT_FORMAT }}" SPNameQualifier="{{ SP_NAME_QUALIFIER }}">
{{ SUBJECT }}
</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData {{ IN_RESPONSE_TO|safe }} NotOnOrAfter="{{ NOT_ON_OR_AFTER }}" Recipient="{{ ACS_URL }}" />
</saml:SubjectConfirmation>
</saml:Subject>

12
passbook/saml_idp/urls.py Normal file
View File

@ -0,0 +1,12 @@
"""Supervisr SAML IDP URLs"""
from django.conf.urls import url
from passbook.saml_idp import views
urlpatterns = [
url(r'^login/$', views.login_begin, name="saml_login_begin"),
url(r'^login/process/$', views.login_process, name='saml_login_process'),
url(r'^logout/$', views.logout, name="saml_logout"),
url(r'^metadata/xml/$', views.descriptor, name='metadata_xml'),
url(r'^settings/$', views.IDPSettingsView.as_view(), name='admin_settings'),
]

218
passbook/saml_idp/views.py Normal file
View File

@ -0,0 +1,218 @@
"""passbook SAML IDP Views"""
from logging import getLogger
from django.contrib import auth, messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
from django.http import (Http404, HttpResponse, HttpResponseBadRequest,
HttpResponseRedirect)
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.datastructures import MultiValueDictKeyError
from django.utils.html import escape
from django.utils.translation import ugettext as _
from django.views.decorators.csrf import csrf_exempt
from OpenSSL.crypto import FILETYPE_PEM
from OpenSSL.crypto import Error as CryptoError
from OpenSSL.crypto import load_certificate
from passbook.core.models import Event, Setting, UserAcquirableRelationship
from passbook.core.utils import render_to_string
from passbook.core.views.common import ErrorResponseView
from passbook.core.views.settings import GenericSettingView
from passbook.mod.auth.saml.idp import exceptions, registry, xml_signing
from passbook.mod.auth.saml.idp.forms.settings import IDPSettingsForm
LOGGER = getLogger(__name__)
URL_VALIDATOR = URLValidator(schemes=('http', 'https'))
def _generate_response(request, processor, remote):
"""
Generate a SAML response using processor and return it in the proper Django
response.
"""
try:
ctx = processor.generate_response()
ctx['remote'] = remote
except exceptions.UserNotAuthorized:
return render(request, 'saml/idp/invalid_user.html')
return render(request, 'saml/idp/login.html', ctx)
def render_xml(request, template, ctx):
"""Render template with content_type application/xml"""
return render(request, template, context=ctx, content_type="application/xml")
@csrf_exempt
def login_begin(request):
"""
Receives a SAML 2.0 AuthnRequest from a Service Provider and
stores it in the session prior to enforcing login.
"""
if request.method == 'POST':
source = request.POST
else:
source = request.GET
# Store these values now, because Django's login cycle won't preserve them.
try:
request.session['SAMLRequest'] = source['SAMLRequest']
except (KeyError, MultiValueDictKeyError):
return HttpResponseBadRequest('the SAML request payload is missing')
request.session['RelayState'] = source.get('RelayState', '')
return redirect(reverse('passbook_mod_auth_saml_idp:saml_login_process'))
def redirect_to_sp(request, acs_url, saml_response, relay_state):
"""
Return autosubmit form
"""
return render(request, 'core/autosubmit_form.html', {
'url': acs_url,
'attrs': {
'SAMLResponse': saml_response,
'RelayState': relay_state
}
})
@login_required
def login_process(request):
"""
Processor-based login continuation.
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider.
"""
LOGGER.debug("Request: %s", request)
proc, remote = registry.find_processor(request)
# Check if user has access
access = True
if remote.productextensionsaml2_set.exists() and \
remote.productextensionsaml2_set.first().product_set.exists():
# Only check if there is a connection from OAuth2 Application to product
product = remote.productextensionsaml2_set.first().product_set.first()
relationship = UserAcquirableRelationship.objects.filter(user=request.user, model=product)
# Product is invite_only = True and no relation with user exists
if product.invite_only and not relationship.exists():
access = False
# Check if we should just autosubmit
if remote.skip_authorization and access:
# full_res = _generate_response(request, proc, remote)
ctx = proc.generate_response()
# User accepted request
Event.create(
user=request.user,
message=_('You authenticated %s (via SAML) (skipped Authz)' % remote.name),
request=request,
current=False,
hidden=True)
return redirect_to_sp(
request=request,
acs_url=ctx['acs_url'],
saml_response=ctx['saml_response'],
relay_state=ctx['relay_state'])
if request.method == 'POST' and request.POST.get('ACSUrl', None) and access:
# User accepted request
Event.create(
user=request.user,
message=_('You authenticated %s (via SAML)' % remote.name),
request=request,
current=False,
hidden=True)
return redirect_to_sp(
request=request,
acs_url=request.POST.get('ACSUrl'),
saml_response=request.POST.get('SAMLResponse'),
relay_state=request.POST.get('RelayState'))
try:
full_res = _generate_response(request, proc, remote)
if not access:
LOGGER.warning("User '%s' has no invitation to '%s'", request.user, product)
messages.error(request, "You have no access to '%s'" % product.name)
raise Http404
return full_res
except exceptions.CannotHandleAssertion as exc:
return ErrorResponseView.as_view()(request, str(exc))
@csrf_exempt
def logout(request):
"""
Allows a non-SAML 2.0 URL to log out the user and
returns a standard logged-out page. (SalesForce and others use this method,
though it's technically not SAML 2.0).
"""
auth.logout(request)
redirect_url = request.GET.get('redirect_to', '')
try:
URL_VALIDATOR(redirect_url)
except ValidationError:
pass
else:
return HttpResponseRedirect(redirect_url)
return render(request, 'saml/idp/logged_out.html')
@login_required
@csrf_exempt
def slo_logout(request):
"""
Receives a SAML 2.0 LogoutRequest from a Service Provider,
logs out the user and returns a standard logged-out page.
"""
request.session['SAMLRequest'] = request.POST['SAMLRequest']
# TODO: Parse SAML LogoutRequest from POST data, similar to login_process().
# TODO: Add a URL dispatch for this view.
# TODO: Modify the base processor to handle logouts?
# TODO: Combine this with login_process(), since they are so very similar?
# TODO: Format a LogoutResponse and return it to the browser.
# XXX: For now, simply log out without validating the request.
auth.logout(request)
return render(request, 'saml/idp/logged_out.html')
def descriptor(request):
"""
Replies with the XML Metadata IDSSODescriptor.
"""
entity_id = Setting.get('issuer')
slo_url = request.build_absolute_uri(reverse('passbook_mod_auth_saml_idp:saml_logout'))
sso_url = request.build_absolute_uri(reverse('passbook_mod_auth_saml_idp:saml_login_begin'))
pubkey = xml_signing.load_certificate(strip=True)
ctx = {
'entity_id': entity_id,
'cert_public_key': pubkey,
'slo_url': slo_url,
'sso_url': sso_url
}
metadata = render_to_string('saml/xml/metadata.xml', ctx)
response = HttpResponse(metadata, content_type='application/xml')
response['Content-Disposition'] = 'attachment; filename="sv_metadata.xml'
return response
class IDPSettingsView(GenericSettingView):
"""IDP Settings"""
form = IDPSettingsForm
template_name = 'saml/idp/settings.html'
def dispatch(self, request, *args, **kwargs):
self.extra_data['metadata'] = escape(descriptor(request).content.decode('utf-8'))
# Show the certificate fingerprint
sha1_fingerprint = _('<failed to parse certificate>')
try:
cert = load_certificate(FILETYPE_PEM, Setting.get('certificate'))
sha1_fingerprint = cert.digest("sha1")
except CryptoError:
pass
self.extra_data['fingerprint'] = sha1_fingerprint
return super().dispatch(request, *args, **kwargs)

View File

@ -0,0 +1,93 @@
"""Functions for creating XML output."""
from logging import getLogger
from passbook.lib.utils import render_to_string
from passbook.saml_idp.xml_signing import (get_signature_xml, load_certificate,
load_private_key, sign_with_signxml)
LOGGER = getLogger(__name__)
def _get_attribute_statement(params):
"""Inserts AttributeStatement, if we have any attributes.
Modifies the params dict.
PRE-REQ: params['SUBJECT'] has already been created (usually by a call to
_get_subject()."""
attributes = params.get('ATTRIBUTES', [])
if not attributes:
params['ATTRIBUTE_STATEMENT'] = ''
return
# Build complete AttributeStatement.
params['ATTRIBUTE_STATEMENT'] = render_to_string('saml/xml/attributes.xml', {
'attributes': attributes})
def _get_in_response_to(params):
"""Insert InResponseTo if we have a RequestID.
Modifies the params dict."""
# NOTE: I don't like this. We're mixing templating logic here, but the
# current design requires this; maybe refactor using better templates, or
# just bite the bullet and use elementtree to produce the XML; see comments
# in xml_templates about Canonical XML.
request_id = params.get('REQUEST_ID', None)
if request_id:
params['IN_RESPONSE_TO'] = 'InResponseTo="%s" ' % request_id
else:
params['IN_RESPONSE_TO'] = ''
def _get_subject(params):
"""Insert Subject. Modifies the params dict."""
params['SUBJECT_STATEMENT'] = render_to_string('saml/xml/subject.xml', params)
def get_assertion_xml(template, parameters, signed=False):
"""Get XML for Assertion"""
# Reset signature.
params = {}
params.update(parameters)
params['ASSERTION_SIGNATURE'] = ''
_get_in_response_to(params)
_get_subject(params) # must come before _get_attribute_statement()
_get_attribute_statement(params)
unsigned = render_to_string(template, params)
# LOGGER.debug('Unsigned: %s', unsigned)
if not signed:
return unsigned
# Sign it.
signature_xml = get_signature_xml()
params['ASSERTION_SIGNATURE'] = signature_xml
return render_to_string(template, params)
def get_response_xml(parameters, signed=False, assertion_id=''):
"""Returns XML for response, with signatures, if signed is True."""
# Reset signatures.
params = {}
params.update(parameters)
params['RESPONSE_SIGNATURE'] = ''
_get_in_response_to(params)
unsigned = render_to_string('saml/xml/response.xml', params)
# LOGGER.debug('Unsigned: %s', unsigned)
if not signed:
return unsigned
raw_response = render_to_string('saml/xml/response.xml', params)
# Sign it.
if signed:
signature_xml = get_signature_xml()
params['RESPONSE_SIGNATURE'] = signature_xml
# LOGGER.debug("Raw response: %s", raw_response)
signed = sign_with_signxml(
load_private_key(), raw_response, [load_certificate(True)],
reference_uri=assertion_id) \
.decode("utf-8")
return signed
return raw_response

View File

@ -0,0 +1,41 @@
"""Signing code goes here."""
from logging import getLogger
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from defusedxml import ElementTree
from signxml import XMLSigner
from signxml.util import strip_pem_header
from passbook.core.models import Setting
from passbook.lib.utils import render_to_string
LOGGER = getLogger(__name__)
def load_certificate(strip=False):
"""Get Public key from config"""
cert = Setting.get('certificate')
if strip:
return strip_pem_header(cert.replace('\r', '')).replace('\n', '')
return cert
def load_private_key():
"""Get Private Key from config"""
return Setting.get('private_key')
def sign_with_signxml(private_key, data, cert, reference_uri=None):
"""Sign Data with signxml"""
key = serialization.load_pem_private_key(
str.encode('\n'.join([x.strip() for x in private_key.split('\n')])),
password=None, backend=default_backend())
root = ElementTree.fromstring(data)
signer = XMLSigner(c14n_algorithm='http://www.w3.org/2001/10/xml-exc-c14n#')
return ElementTree.tostring(signer.sign(root, key=key, cert=cert, reference_uri=reference_uri))
def get_signature_xml():
"""Returns XML Signature for subject."""
return render_to_string('saml/xml/signature.xml', {})

3
passbook/tfa/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""passbook tfa Header"""
__version__ = '0.0.1-alpha'
default_app_config = 'passbook.tfa.apps.PassbookTFAConfig'

10
passbook/tfa/apps.py Normal file
View File

@ -0,0 +1,10 @@
"""passbook 2FA AppConfig"""
from django.apps.config import AppConfig
class PassbookTFAConfig(AppConfig):
"""passbook TFA AppConfig"""
name = 'passbook.tfa'
label = 'passbook_tfa'

52
passbook/tfa/forms.py Normal file
View File

@ -0,0 +1,52 @@
"""Supervisr 2FA Forms"""
from django import forms
from django.core.validators import RegexValidator
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
TFA_CODE_VALIDATOR = RegexValidator(r'^[0-9a-z]{6,8}$',
_('Only alpha-numeric characters are allowed.'))
class PictureWidget(forms.widgets.Widget):
"""Widget to render value as img-tag"""
def render(self, name, value, attrs=None, renderer=None):
return mark_safe("<img src=\"%s\" />" % value) # nosec
class TFAVerifyForm(forms.Form):
"""Simple Form to verify 2FA Code"""
order = ['code']
code = forms.CharField(label=_('Code'), validators=[TFA_CODE_VALIDATOR],
widget=forms.TextInput(attrs={'autocomplete': 'off'}))
def __init__(self, *args, **kwargs):
super(TFAVerifyForm, self).__init__(*args, **kwargs)
# This is a little helper so the field is focused by default
self.fields['code'].widget.attrs.update({'autofocus': 'autofocus'})
class TFASetupInitForm(forms.Form):
"""Initial 2FA Setup form"""
title = _('Set up 2FA')
device = None
confirmed = False
qr_code = forms.CharField(widget=PictureWidget, disabled=True, required=False,
label=_('Scan this Code with your 2FA App.'))
code = forms.CharField(label=_('Code'), validators=[TFA_CODE_VALIDATOR])
def clean_code(self):
"""Check code with new totp device"""
if self.device is not None:
if not self.device.verify_token(int(self.cleaned_data.get('code'))) \
and not self.confirmed:
raise forms.ValidationError(_("2FA Code does not match"))
return self.cleaned_data.get('code')
class TFASetupStaticForm(forms.Form):
"""Static form to show generated static tokens"""
tokens = forms.MultipleChoiceField(disabled=True, required=False)

View File

@ -0,0 +1,31 @@
"""passbook 2FA Middleware to force users with 2FA set up to verify"""
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import urlencode
from django_otp import user_has_device
def tfa_force_verify(get_response):
"""Middleware to force 2FA Verification"""
def middleware(request):
"""Middleware to force 2FA Verification"""
# pylint: disable=too-many-boolean-expressions
if request.user.is_authenticated and \
user_has_device(request.user) and \
not request.user.is_verified() and \
request.path != reverse('passbook_tfa:tfa-verify') and \
request.path != reverse('account-logout') and \
not request.META.get('HTTP_AUTHORIZATION', '').startswith('Bearer'):
# User has 2FA set up but is not verified
# At this point the request is already forwarded to the target destination
# So we just add the current request's path as next parameter
args = '?%s' % urlencode({'next': request.get_full_path()})
return redirect(reverse('passbook_tfa:tfa-verify') + args)
response = get_response(request)
return response
return middleware

View File

@ -0,0 +1 @@
django-two-factor-auth

13
passbook/tfa/settings.py Normal file
View File

@ -0,0 +1,13 @@
"""passbook 2FA Settings"""
OTP_LOGIN_URL = 'passbook_tfa:tfa-verify'
OTP_TOTP_ISSUER = 'passbook'
MIDDLEWARE = [
'django_otp.middleware.OTPMiddleware',
'passbook.tfa.middleware.tfa_force_verify',
]
INSTALLED_APPS = [
'django_otp',
'django_otp.plugins.otp_static',
'django_otp.plugins.otp_totp',
]

View File

@ -0,0 +1,54 @@
{% extends "user/base.html" %}
{% load supervisr_utils %}
{% load i18n %}
{% load hostname %}
{% load setting %}
{% load fieldtype %}
{% block title %}
{% title "Overview" %}
{% endblock %}
{% block content %}
<h1><clr-icon shape="two-way-arrows" size="48"></clr-icon>{% trans "2-Factor Authentication" %}</h1>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
{% trans "Status" %}
</div>
<div class="card-footer">
<p>
{% blocktrans with state=state|yesno:"Enabled,Disabled" %}
Status: {{ state }}
{% endblocktrans %}
{% if state %}
<clr-icon shape="check" size="32" class="is-success"></clr-icon>
{% else %}
<clr-icon shape="times" size="32" class="is-error"></clr-icon>
{% endif %}
</p>
<p>
{% if not state %}
<a href="{% url 'supervisr_mod_tfa:tfa-enable' %}" class="btn btn-success btn-sm">{% trans "Enable 2FA" %}</a>
{% else %}
<a href="{% url 'supervisr_mod_tfa:tfa-disable' %}" class="btn btn-danger btn-sm">{% trans "Disable 2FA" %}</a>
{% endif %}
</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
{% trans "Your Backup tokens:" %}
</div>
<div class="card-block">
<pre>{% for token in static_tokens %}{{ token.token }}
{% endfor %}</pre>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,20 @@
{% extends "generic/wizard.html" %}
{% load supervisr_utils %}
{% block title %}
{% title "Setup" %}
{% endblock %}
{% block form %}
<label for="">Keep these tokens somewhere safe. These are to be used if you loose your primary 2FA device.</label>
{% for field in wizard.form %}
{% if field.field.widget|fieldtype == 'SelectMultiple' %}
<ul class="list">
{% for token in field.field.choices %}
<li>{{ token.0 }}</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
{% endblock %}

View File

View File

@ -0,0 +1,31 @@
"""
Supervisr Mod 2FA Middleware Test
"""
import os
from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory, TestCase
from django.urls import reverse
from supervisr.core.views import common
from supervisr.mod.tfa.middleware import tfa_force_verify
class TestMiddleware(TestCase):
"""
Supervisr 2FA Middleware Test
"""
def setUp(self):
os.environ['RECAPTCHA_TESTING'] = 'True'
self.factory = RequestFactory()
def test_tfa_force_verify_anon(self):
"""
Test Anonymous TFA Force
"""
request = self.factory.get(reverse('common-index'))
request.user = AnonymousUser()
response = tfa_force_verify(common.IndexView.as_view())(request)
self.assertEqual(response.status_code, 302)

Some files were not shown because too many files have changed in this diff Show More