Many broken things
This commit is contained in:
parent
79490984d1
commit
fbaab4efaf
|
@ -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())),
|
||||
# ]
|
|
@ -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()
|
|
@ -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__'
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
django-crispy-forms
|
|
@ -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 %}
|
|
@ -5,11 +5,14 @@
|
|||
|
||||
{% block nav_secondary %}
|
||||
<ul class="nav navbar-nav navbar-persistent">
|
||||
<li class="active">
|
||||
<a href="#">{% trans 'Overview' %}</a>
|
||||
<li class="{% is_active 'passbook_admin:overview' %}">
|
||||
<a href="{% url 'passbook_admin:overview' %}">{% trans 'Overview' %}</a>
|
||||
</li>
|
||||
<li class="{% is_active 'applications'}">
|
||||
<a href="#">{% trans 'Applications' %}</a>
|
||||
<li class="{% is_active 'passbook_admin:applications' %}">
|
||||
<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>
|
||||
<a href="#">{% trans 'Rules' %}</a>
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
{% extends "administration/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% endblock %}
|
|
@ -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 %}
|
|
@ -0,0 +1,7 @@
|
|||
{% extends "generic/form.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block above_form %}
|
||||
<h1>{% trans 'Create' %}</h1>
|
||||
{% endblock %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -0,0 +1,7 @@
|
|||
{% extends "generic/form.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block above_form %}
|
||||
<h1>{% trans 'Update' %}</h1>
|
||||
{% endblock %}
|
|
@ -1,11 +1,19 @@
|
|||
"""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 = [
|
||||
path('', overview.AdministrationOverviewView.as_view(), name='admin-overview'),
|
||||
path('applications/', applications.ApplicationListView.as_view(), name='admin-applications'),
|
||||
path('', overview.AdministrationOverviewView.as_view(), name='overview'),
|
||||
path('applications/', applications.ApplicationListView.as_view(),
|
||||
name='applications'),
|
||||
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'))
|
||||
]
|
||||
|
|
|
@ -2,14 +2,16 @@
|
|||
|
||||
from django.views.generic import CreateView, DeleteView, ListView, UpdateView
|
||||
|
||||
from passbook.admin.mixins import AdminRequiredMixin
|
||||
from passbook.core.models import Application
|
||||
|
||||
|
||||
class ApplicationListView(ListView):
|
||||
class ApplicationListView(AdminRequiredMixin, ListView):
|
||||
model = Application
|
||||
template_name = 'administration/list.html'
|
||||
template_name = 'administration/application/list.html'
|
||||
|
||||
class ApplicationCreateView(CreateView):
|
||||
|
||||
class ApplicationCreateView(AdminRequiredMixin, CreateView):
|
||||
|
||||
model = Application
|
||||
template_name = 'administration/application/create.html'
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from passbook.admin.mixins import AdminRequiredMixin
|
||||
from passbook.core.models import Application, Rule, User
|
||||
|
||||
|
||||
class AdministrationOverviewView(LoginRequiredMixin, TemplateView):
|
||||
class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
|
||||
|
||||
template_name = 'administration/overview.html'
|
||||
|
||||
|
|
|
@ -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()
|
|
@ -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.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -45,9 +47,9 @@ class Migration(migrations.Migration):
|
|||
migrations.CreateModel(
|
||||
name='Application',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateField(auto_now_add=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True)),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.TextField()),
|
||||
('launch_url', models.URLField(blank=True, null=True)),
|
||||
('icon_url', models.TextField(blank=True, null=True)),
|
||||
|
@ -59,9 +61,9 @@ class Migration(migrations.Migration):
|
|||
migrations.CreateModel(
|
||||
name='Rule',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateField(auto_now_add=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)),
|
||||
('action', models.CharField(choices=[('allow', 'allow'), ('deny', 'deny')], max_length=20)),
|
||||
('negate', models.BooleanField(default=False)),
|
||||
|
@ -73,9 +75,9 @@ class Migration(migrations.Migration):
|
|||
migrations.CreateModel(
|
||||
name='Source',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateField(auto_now_add=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True)),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.TextField()),
|
||||
('slug', models.SlugField()),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
|
|
|
@ -5,8 +5,9 @@ from logging import getLogger
|
|||
import reversion
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
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__)
|
||||
|
||||
|
@ -17,7 +18,7 @@ class User(AbstractUser):
|
|||
sources = models.ManyToManyField('Source', through='UserSourceConnection')
|
||||
|
||||
@reversion.register()
|
||||
class Application(CastableModel, CreatedUpdatedModel):
|
||||
class Application(UUIDModel, CreatedUpdatedModel):
|
||||
"""Every Application which uses passbook for authentication/identification/authorization
|
||||
needs an Application record. Other authentication types can subclass this Model to
|
||||
add custom fields and other properties"""
|
||||
|
@ -26,6 +27,8 @@ class Application(CastableModel, CreatedUpdatedModel):
|
|||
launch_url = models.URLField(null=True, blank=True)
|
||||
icon_url = models.TextField(null=True, blank=True)
|
||||
|
||||
objects = InheritanceManager()
|
||||
|
||||
def user_is_authorized(self, user: User) -> bool:
|
||||
"""Check if user is authorized to use this application"""
|
||||
raise NotImplementedError()
|
||||
|
@ -34,14 +37,16 @@ class Application(CastableModel, CreatedUpdatedModel):
|
|||
return self.name
|
||||
|
||||
@reversion.register()
|
||||
class Source(CastableModel, CreatedUpdatedModel):
|
||||
class Source(UUIDModel, CreatedUpdatedModel):
|
||||
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
|
||||
|
||||
name = models.TextField()
|
||||
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)
|
||||
|
||||
objects = InheritanceManager()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
@ -57,7 +62,7 @@ class UserSourceConnection(CreatedUpdatedModel):
|
|||
unique_together = (('user', 'source'),)
|
||||
|
||||
@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
|
||||
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)
|
||||
negate = models.BooleanField(default=False)
|
||||
|
||||
objects = InheritanceManager()
|
||||
|
||||
def __str__(self):
|
||||
if self.name:
|
||||
return self.name
|
||||
|
|
|
@ -2,3 +2,6 @@ django>=2.0
|
|||
django-reversion
|
||||
PyYAML
|
||||
raven
|
||||
djangorestframework
|
||||
markdown
|
||||
django-model-utils
|
|
@ -10,10 +10,12 @@ For the full list of settings and their values, see
|
|||
https://docs.djangoproject.com/en/2.1/ref/settings/
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
|
||||
from passbook.lib.config import CONFIG
|
||||
from passbook import __version__
|
||||
from passbook.lib.config import CONFIG
|
||||
|
||||
VERSION = __version__
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
|
@ -30,7 +32,7 @@ DEBUG = True
|
|||
INTERNAL_IPS = ['127.0.0.1']
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
LOGIN_URL = 'auth-login'
|
||||
LOGIN_URL = 'passbook_core:auth-login'
|
||||
|
||||
# Custom user model
|
||||
AUTH_USER_MODEL = 'passbook_core.User'
|
||||
|
@ -41,7 +43,6 @@ AUTHENTICATION_BACKENDS = [
|
|||
'passbook.oauth_client.backends.AuthorizedServiceBackend'
|
||||
]
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
|
@ -54,11 +55,21 @@ INSTALLED_APPS = [
|
|||
'reversion',
|
||||
'passbook.core',
|
||||
'passbook.admin',
|
||||
'rest_framework',
|
||||
'passbook.lib',
|
||||
'passbook.ldap',
|
||||
'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 = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
|
@ -133,6 +144,8 @@ USE_L10N = True
|
|||
|
||||
USE_TZ = True
|
||||
|
||||
OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application'
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/2.1/howto/static-files/
|
||||
|
@ -230,3 +243,18 @@ with CONFIG.cd('log'):
|
|||
if DEBUG:
|
||||
INSTALLED_APPS.append('debug_toolbar')
|
||||
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
|
||||
|
|
|
@ -2,11 +2,9 @@
|
|||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load is_active %}
|
||||
|
||||
{% block body %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
<nav class="navbar navbar-default navbar-pf" role="navigation">
|
||||
<div class="navbar-header">
|
||||
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse-1">
|
||||
|
@ -55,20 +53,21 @@
|
|||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="nav navbar-nav navbar-primary persistent-secondary">
|
||||
<ul class="nav navbar-nav navbar-primary">
|
||||
{# FIXME: Detect active application #}
|
||||
<li class="active">
|
||||
<a href="#0">{% trans 'Overview' %}</a>
|
||||
<li class="{% is_active_app 'passbook_core' %}">
|
||||
<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 %}
|
||||
{% endblock %}
|
||||
</li>
|
||||
<li>
|
||||
<a href="#0">{% trans 'Administration' %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container-fluid container-cards-pf">
|
||||
{% include 'partials/messages.html' %}
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
|
|
@ -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 %}
|
|
@ -8,15 +8,25 @@ from django.views.generic import RedirectView
|
|||
from passbook.core.views import authentication, overview
|
||||
|
||||
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('', overview.OverviewView.as_view(), name='overview'),
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
# Core
|
||||
path('', include((core_urls, 'passbook_core'), namespace='passbook_core')),
|
||||
# Administration
|
||||
path('administration/django/', admin.site.urls),
|
||||
path('administration/', include('passbook.admin.urls')),
|
||||
path('', include('passbook.oauth_client.urls')),
|
||||
path('administration/',
|
||||
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:
|
||||
|
|
|
@ -84,7 +84,7 @@ class LoginView(UserPassesTestMixin, FormView):
|
|||
if 'next' in request.GET:
|
||||
return redirect(request.GET.get('next'))
|
||||
# Otherwise just index
|
||||
return redirect(reverse('overview'))
|
||||
return redirect(reverse('passbook_core:overview'))
|
||||
|
||||
@staticmethod
|
||||
def invalid_login(request: HttpRequest, disabled_user: User = None) -> HttpResponse:
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
"""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 logging import getLogger
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
from passbook.lib.config import CONFIG
|
||||
|
||||
LOGGER = getLogger(__name__)
|
||||
|
||||
class PassbookOAuthClientConfig(AppConfig):
|
||||
|
|
|
@ -5,6 +5,7 @@ from django.db.models import Q
|
|||
|
||||
from passbook.oauth_client.models import OAuthSource, UserOAuthSourceConnection
|
||||
|
||||
|
||||
class AuthorizedServiceBackend(ModelBackend):
|
||||
"Authentication backend for users registered with remote OAuth provider."
|
||||
|
||||
|
|
|
@ -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
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
|
|
@ -9,8 +9,6 @@ from passbook.oauth_client.clients import get_client
|
|||
class OAuthSource(Source):
|
||||
"""Configuration for OAuth provider."""
|
||||
|
||||
# FIXME: Dynamically load available source_types
|
||||
|
||||
provider_type = models.CharField(max_length=255)
|
||||
request_token_url = models.CharField(blank=True, max_length=255)
|
||||
authorization_url = models.CharField(max_length=255)
|
||||
|
|
|
@ -6,9 +6,9 @@ from django.contrib.auth import get_user_model
|
|||
from requests.exceptions import RequestException
|
||||
|
||||
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.views.core import OAuthCallback, OAuthRedirect
|
||||
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
|
||||
|
||||
LOGGER = getLogger(__name__)
|
||||
|
||||
|
|
|
@ -3,9 +3,9 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
|
||||
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.views.core import OAuthCallback, OAuthRedirect
|
||||
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
|
||||
|
||||
|
||||
@MANAGER.source(kind=RequestKind.redirect, name='facebook')
|
||||
|
|
|
@ -3,9 +3,9 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
|
||||
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.views.core import OAuthCallback
|
||||
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
|
||||
|
||||
|
||||
@MANAGER.source(kind=RequestKind.callback, name='github')
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
"""Google OAuth Views"""
|
||||
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.views.core import OAuthCallback, OAuthRedirect
|
||||
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
|
||||
|
||||
|
||||
@MANAGER.source(kind=RequestKind.redirect, name='google')
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
"""Source type manager"""
|
||||
from logging import getLogger
|
||||
from enum import Enum
|
||||
from logging import getLogger
|
||||
|
||||
from passbook.oauth_client.views.core import OAuthCallback, OAuthRedirect
|
||||
|
||||
LOGGER = getLogger(__name__)
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
|
||||
"""Reddit OAuth Views"""
|
||||
import json
|
||||
from logging import getLogger
|
||||
|
@ -8,13 +7,13 @@ from requests.auth import HTTPBasicAuth
|
|||
from requests.exceptions import RequestException
|
||||
|
||||
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.views.core import OAuthCallback, OAuthRedirect
|
||||
|
||||
LOGGER = getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@MANAGER.source(kind=RequestKind.redirect, name='reddit')
|
||||
class RedditOAuthRedirect(OAuthRedirect):
|
||||
"""Reddit OAuth2 Redirect"""
|
||||
|
|
|
@ -7,9 +7,9 @@ from django.contrib.auth import get_user_model
|
|||
from requests.exceptions import RequestException
|
||||
|
||||
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.views.core import OAuthCallback
|
||||
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
|
||||
|
||||
LOGGER = getLogger(__name__)
|
||||
|
||||
|
|
|
@ -7,9 +7,9 @@ from requests.exceptions import RequestException
|
|||
|
||||
from passbook.oauth_client.clients import OAuthClient
|
||||
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.views.core import OAuthCallback
|
||||
from passbook.oauth_client.source_types.manager import MANAGER, RequestKind
|
||||
|
||||
LOGGER = getLogger(__name__)
|
||||
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
"""passbook oauth_provider Header"""
|
||||
__version__ = '0.0.1-alpha'
|
||||
default_app_config = 'passbook.oauth_provider.apps.PassbookOAuthProviderConfig'
|
|
@ -0,0 +1,4 @@
|
|||
"""passbook oauth provider Admin"""
|
||||
from passbook.lib.admin import admin_autoregister
|
||||
|
||||
admin_autoregister('passbook_oauth_provider')
|
|
@ -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'
|
|
@ -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 ""
|
|
@ -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 ""
|
|
@ -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 ""
|
|
@ -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 ""
|
|
@ -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',),
|
||||
),
|
||||
]
|
|
@ -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)
|
|
@ -0,0 +1,2 @@
|
|||
django-oauth-toolkit
|
||||
django-cors-middleware
|
|
@ -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',
|
||||
]
|
|
@ -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')),
|
||||
]
|
|
@ -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)
|
|
@ -0,0 +1,3 @@
|
|||
"""passbook saml_idp Header"""
|
||||
__version__ = '0.0.1-alpha'
|
||||
default_app_config = 'passbook.saml_idp.apps.PassbookSAMLIDPConfig'
|
|
@ -0,0 +1,5 @@
|
|||
"""SAML IDP Admin"""
|
||||
|
||||
from passbook.lib.admin import admin_autoregister
|
||||
|
||||
admin_autoregister('passbook_saml_idp')
|
|
@ -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'
|
|
@ -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
|
|
@ -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', '')
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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', '')
|
|
@ -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')
|
|
@ -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)
|
|
@ -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"
|
|
@ -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/'
|
|
@ -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.')
|
|
@ -0,0 +1,4 @@
|
|||
beautifulsoup4>=4.6.0
|
||||
lxml>=3.8.0
|
||||
signxml
|
||||
defusedxml
|
|
@ -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,
|
||||
}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -0,0 +1,5 @@
|
|||
{% extends "saml/idp/base.html" %}
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
{% trans "You have successfully logged out of the Identity Provider." %}
|
||||
{% endblock %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
|||
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="placeholder"></ds:Signature>
|
|
@ -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>
|
|
@ -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'),
|
||||
]
|
|
@ -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)
|
|
@ -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
|
|
@ -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', {})
|
|
@ -0,0 +1,3 @@
|
|||
"""passbook tfa Header"""
|
||||
__version__ = '0.0.1-alpha'
|
||||
default_app_config = 'passbook.tfa.apps.PassbookTFAConfig'
|
|
@ -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'
|
|
@ -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)
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
django-two-factor-auth
|
|
@ -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',
|
||||
]
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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
Reference in New Issue