Compare commits

..

53 Commits

Author SHA1 Message Date
04a5428148 new release: 0.7.16-beta 2020-02-17 21:02:54 +01:00
73b173b92a admin: fix form missing on update pages 2020-02-17 21:02:47 +01:00
7cbf20a71c admin: fix CodeMirror field not loading correctly 2020-02-17 21:02:35 +01:00
7a98e6d92b new release: 0.7.15-beta 2020-02-17 20:45:56 +01:00
49e915f98b Merge pull request #4 from BeryJu/propertymapping-jinja
PropertyMappings using Jinja
2020-02-17 20:45:04 +01:00
3aa2f1e892 *: propertymapping template -> expression 2020-02-17 20:38:14 +01:00
bc4b7ef44d providers/saml: add custom help text for templates, add docs for User Object reference 2020-02-17 20:30:14 +01:00
9400b01a55 admin: parameterise generic from's base template 2020-02-17 20:29:41 +01:00
e57da71dcf sources/ldap: update LDAP source to use new property mappings 2020-02-17 17:55:48 +01:00
7268afaaf9 providers/saml: update to new PropertyMappings 2020-02-17 17:50:11 +01:00
205183445c admin: add support for template field and Jinja2 highlighting 2020-02-17 17:48:53 +01:00
a08bdfdbcd root: remove prospector from Pipfile as it causes lock issues, install in CI 2020-02-17 17:48:18 +01:00
e6c47fee26 core: add template field to PropertyMapping 2020-02-17 17:47:51 +01:00
a5629c5155 providers/saml: add changeable signature and digest algorithm 2020-02-17 16:28:18 +01:00
41689fe3ce sources/* add missing migrations 2020-02-17 16:27:35 +01:00
8e84208e2c new release: 0.7.14-beta 2020-02-17 15:42:14 +01:00
32a48fa07a providers/saml: more typehints 2020-02-17 15:40:49 +01:00
773a9c0692 policies/engine: fix cached policy results being ignored 2020-02-17 15:37:51 +01:00
8808e3afe0 policies/engine: set mp start method to fork to fix issues under macOS 2020-02-17 15:20:30 +01:00
ecea85f8ca lib/config: remove autoreload handler as this API is gone in django 3 2020-02-17 15:20:11 +01:00
5dfa141e35 root/wsgi: log requests with event name of request 2020-02-16 14:36:31 +01:00
447e81d0b8 providers/saml: handle uncompressed SAML AuthNRequest 2020-02-16 14:08:35 +01:00
e138076e1d sources/saml: move labels from forms to models 2020-02-16 12:34:46 +01:00
721d133dc3 sources/oauth: move labels from form to models 2020-02-16 12:34:33 +01:00
75b687ecbe sources/ldap: move labels from form to models 2020-02-16 12:30:45 +01:00
bdd1863177 providers/saml: move field labels from Form into models 2020-02-16 12:30:26 +01:00
e5b85e8e6a providers/saml: move default saml properties to DB 2020-02-16 12:29:53 +01:00
d7481c9de7 new release: 0.7.13-beta 2020-02-14 15:35:05 +01:00
571373866e providers/saml: some more cleanup, fix get_time_string when called without argument 2020-02-14 15:34:24 +01:00
e36d7928e4 providers/saml: big cleanup, simplify base processor
add New fields for
 - assertion_valid_not_before
 - assertion_valid_not_on_or_after
 - session_valid_not_on_or_after
allow flexible time durations for these fields
fall back to Provider's ACS if none is specified in AuthNRequest
2020-02-14 15:19:48 +01:00
2be026dd44 global: fix import order 2020-02-14 15:17:40 +01:00
d5b9de3569 Merge pull request #3 from BeryJu/dependabot/pip/django-2.2.10
build(deps): bump django from 2.2.9 to 2.2.10
2020-02-12 09:31:13 +01:00
e22620b0ec build(deps): bump django from 2.2.9 to 2.2.10
Bumps [django](https://github.com/django/django) from 2.2.9 to 2.2.10.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/2.2.9...2.2.10)

Signed-off-by: dependabot[bot] <support@github.com>
2020-02-12 03:20:09 +00:00
ba74a3213d *: upgrade python 3.7 to 3.8 2020-01-19 21:03:01 +01:00
d9ecb7070d core: add more prometheus metrics 2020-01-19 21:01:26 +01:00
fc4a46bd9c root: fix credential variables overwriting each other 2020-01-17 11:16:23 +01:00
78301b7bab docs: fix site_url 2020-01-17 10:55:20 +01:00
7bf7bde856 root: fix prometheus path in ServiceMonitor, return WWW-Authenticate header so basic auth is sent 2020-01-17 10:55:11 +01:00
9bdff14403 providers/app_gw: fix wrong UPSTREAM parameter 2020-01-03 09:15:07 +01:00
f124314eab new release: 0.7.12-beta 2020-01-02 20:22:44 +01:00
684e4ffdcf providers/app_gw: fix formatting 2020-01-02 20:22:36 +01:00
d9ff5c69c8 providers/app_gw: fix assignment of response_types 2020-01-02 20:20:10 +01:00
8142e3df45 providers/oidc: fix application property of wrong object being used 2020-01-02 20:19:53 +01:00
73920899de static: use current pixie image 2020-01-02 20:09:30 +01:00
13666965a7 actions: fix build over gatekeeper 2020-01-02 16:55:30 +01:00
86f16e2781 providers/oidc: fix incorrectly sorted imports 2020-01-02 16:42:52 +01:00
2ed8e72c62 new release: 0.7.11-beta 2020-01-02 16:38:11 +01:00
edeed18ae8 providers/oidc: fix error when using with app_gw 2020-01-02 16:38:01 +01:00
d24133d8a2 core: fix _redirect_with_qs appending an array to the URL 2020-01-02 16:14:56 +01:00
b9733e56aa providers/app_gw: fix passbook domain being empty 2020-01-02 16:09:17 +01:00
cd34413914 providers/app_gw: separate host field into external_ and internal_ 2020-01-02 16:09:04 +01:00
c3a4a76d43 providers/app_gw: fix Client's response_type not being set 2020-01-02 16:06:32 +01:00
a59a29b256 actions: also build gatekeeper on release 2020-01-02 15:55:39 +01:00
74 changed files with 1537 additions and 998 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.7.10-beta
current_version = 0.7.16-beta
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)

View File

@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@v1
- uses: actions/setup-python@v1
with:
python-version: '3.7'
python-version: '3.8'
- uses: actions/cache@v1
with:
path: ~/.local/share/virtualenvs/
@ -31,7 +31,7 @@ jobs:
- uses: actions/checkout@v1
- uses: actions/setup-python@v1
with:
python-version: '3.7'
python-version: '3.8'
- uses: actions/cache@v1
with:
path: ~/.local/share/virtualenvs/
@ -48,7 +48,7 @@ jobs:
- uses: actions/checkout@v1
- uses: actions/setup-python@v1
with:
python-version: '3.7'
python-version: '3.8'
- uses: actions/cache@v1
with:
path: ~/.local/share/virtualenvs/
@ -56,7 +56,7 @@ jobs:
restore-keys: |
${{ runner.os }}-pipenv-
- name: Install dependencies
run: pip install -U pip pipenv && pipenv install --dev
run: pip install -U pip pipenv && pipenv install --dev && pipenv install --dev prospector --skip-lock
- name: Lint with prospector
run: pipenv run prospector
bandit:
@ -65,7 +65,7 @@ jobs:
- uses: actions/checkout@v1
- uses: actions/setup-python@v1
with:
python-version: '3.7'
python-version: '3.8'
- uses: actions/cache@v1
with:
path: ~/.local/share/virtualenvs/
@ -100,7 +100,7 @@ jobs:
- uses: actions/checkout@v1
- uses: actions/setup-python@v1
with:
python-version: '3.7'
python-version: '3.8'
- uses: actions/cache@v1
with:
path: ~/.local/share/virtualenvs/
@ -134,7 +134,7 @@ jobs:
- uses: actions/checkout@v1
- uses: actions/setup-python@v1
with:
python-version: '3.7'
python-version: '3.8'
- uses: actions/cache@v1
with:
path: ~/.local/share/virtualenvs/

View File

@ -16,13 +16,34 @@ jobs:
- name: Building Docker Image
run: docker build
--no-cache
-t beryju/passbook:0.7.10-beta
-t beryju/passbook:0.7.16-beta
-t beryju/passbook:latest
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook:0.7.10-beta
run: docker push beryju/passbook:0.7.16-beta
- name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook:latest
build-gatekeeper:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Docker Login Registry
env:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
- name: Building Docker Image
run: |
cd gatekeeper
docker build \
--no-cache \
-t beryju/passbook-gatekeeper:0.7.16-beta \
-t beryju/passbook-gatekeeper:latest \
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-gatekeeper:0.7.16-beta
- name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook-gatekeeper:latest
build-static:
runs-on: ubuntu-latest
services:
@ -45,11 +66,11 @@ jobs:
run: docker build
--no-cache
--network=$(docker network ls | grep github | awk '{print $1}')
-t beryju/passbook-static:0.7.10-beta
-t beryju/passbook-static:0.7.16-beta
-t beryju/passbook-static:latest
-f static.Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-static:0.7.10-beta
run: docker push beryju/passbook-static:0.7.16-beta
- name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook-static:latest
test-release:

View File

@ -1,4 +1,4 @@
FROM python:3.7-slim-buster as locker
FROM python:3.8-slim-buster as locker
COPY ./Pipfile /app/
COPY ./Pipfile.lock /app/
@ -9,7 +9,7 @@ RUN pip install pipenv && \
pipenv lock -r > requirements.txt && \
pipenv lock -rd > requirements-dev.txt
FROM python:3.7-slim-buster
FROM python:3.8-slim-buster
COPY --from=locker /app/requirements.txt /app/
COPY --from=locker /app/requirements-dev.txt /app/

View File

@ -40,9 +40,10 @@ signxml = "*"
structlog = "*"
swagger-spec-validator = "*"
urllib3 = {extras = ["secure"],version = "*"}
jinja2 = "*"
[requires]
python_version = "3.7"
python_version = "3.8"
[dev-packages]
autopep8 = "*"
@ -51,7 +52,6 @@ bumpversion = "*"
colorama = "*"
coverage = "*"
django-debug-toolbar = "*"
prospector = "*"
pylint = "*"
pylint-django = "*"
unittest-xml-reporting = "*"

742
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,7 @@
# passbook
![](https://github.com/BeryJu/passbook/workflows/passbook-ci/badge.svg)
## Quick instance
```

View File

@ -7,4 +7,4 @@ threads = 2
enable-threads = true
uid = passbook
gid = passbook
disable-logging=True
disable-logging = True

View File

@ -1,4 +1,4 @@
FROM python:3.7-slim-buster as builder
FROM python:3.8-slim-buster as builder
WORKDIR /mkdocs

View File

@ -0,0 +1,20 @@
# Passbook User Object
The User object has the following attributes:
- `username`: User's Username
- `email` User's E-Mail
- `name` User's Display Name
- `is_staff` Boolean field if user is staff
- `is_active` Boolean field if user is active
- `date_joined` Date User joined/was created
- `password_change_date` Date Password was last changed
- `attributes` Dynamic Attributes
## Examples
List all the User's Group Names
```jinja2
[{% for group in user.groups.all() %}'{{ group.name }}',{% endfor %}]
```

View File

@ -1,6 +1,6 @@
apiVersion: v1
appVersion: "0.7.10-beta"
appVersion: "0.7.16-beta"
description: A Helm chart for passbook.
name: passbook
version: "0.7.10-beta"
version: "0.7.16-beta"
icon: https://git.beryju.org/uploads/-/system/project/avatar/108/logo.png

View File

@ -18,6 +18,7 @@ spec:
name: {{ include "passbook.fullname" . }}-secret-key
key: monitoring_username
port: http
path: /metrics/
interval: 10s
selector:
matchLabels:

View File

@ -2,7 +2,7 @@
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
image:
tag: 0.7.10-beta
tag: 0.7.16-beta
nameOverride: ""

View File

@ -1,5 +1,5 @@
site_name: passbook Docs
site_url: https://docs.passbook.beryju.org
site_url: https://beryju.github.io/passbook
copyright: "Copyright &copy; 2019 - 2020 BeryJu.org"
nav:
@ -19,6 +19,9 @@ nav:
- Rancher: integrations/services/rancher/index.md
- Harbor: integrations/services/harbor/index.md
- Sentry: integrations/services/sentry/index.md
- Reference:
- Property Mappings:
- User Object: reference/property-mappings/user-object.md
repo_name: "BeryJu.org/passbook"
repo_url: https://github.com/BeryJu/passbook

View File

@ -1,2 +1,2 @@
"""passbook"""
__version__ = "0.7.10-beta"
__version__ = "0.7.16-beta"

View File

@ -1,4 +1,4 @@
{% extends "generic/form.html" %}
{% extends base_template|default:"generic/form.html" %}
{% load utils %}
{% load i18n %}

View File

@ -20,6 +20,7 @@
<link rel="stylesheet" href="{% static 'codemirror/lib/codemirror.css' %}">
<link rel="stylesheet" href="{% static 'codemirror/theme/monokai.css' %}">
<script src="{% static 'codemirror/mode/yaml/yaml.js' %}"></script>
<script src="{% static 'codemirror/mode/jinja2/jinja2.js' %}"></script>
{% endblock %}
{% block content %}
@ -29,21 +30,33 @@
<div class="">
<form action="" method="post" class="form-horizontal">
{% include 'partials/form.html' with form=form %}
{% block beneath_form %}
{% endblock %}
<a class="btn btn-default" href="{% back %}">{% trans "Cancel" %}</a>
<input type="submit" class="btn btn-primary" value="{% block action %}{% endblock %}" />
</form>
</div>
{% block beneath_form %}
{% endblock %}
<script>
let attributes = document.getElementsByName('attributes');
const attributes = document.getElementsByName('attributes');
if (attributes.length > 0) {
let myCodeMirror = CodeMirror.fromTextArea(attributes[0], {
// https://github.com/codemirror/CodeMirror/issues/5092
attributes[0].removeAttribute("required");
const attributesCM = CodeMirror.fromTextArea(attributes[0], {
mode: 'yaml',
theme: 'monokai',
lineNumbers: true,
});
}
const expressions = document.getElementsByName('expression');
if (expressions.length > 0) {
// https://github.com/codemirror/CodeMirror/issues/5092
expressions[0].removeAttribute("required");
const expressionCM = CodeMirror.fromTextArea(expressions[0], {
mode: 'jinja2',
theme: 'monokai',
lineNumbers: true,
});
}
</script>
</div>
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends "generic/form.html" %}
{% extends base_template|default:"generic/form.html" %}
{% load utils %}
{% load i18n %}

View File

@ -66,6 +66,9 @@ class PropertyMappingCreateView(
if x.__name__ == property_mapping_type
)
kwargs["type"] = model._meta.verbose_name
form_cls = self.get_form_class()
if hasattr(form_cls, "template_name"):
kwargs["base_template"] = form_cls.template_name
return kwargs
def get_form_class(self):
@ -92,6 +95,13 @@ class PropertyMappingUpdateView(
success_url = reverse_lazy("passbook_admin:property-mappings")
success_message = _("Successfully updated Property Mapping")
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
form_cls = self.get_form_class()
if hasattr(form_cls, "template_name"):
kwargs["base_template"] = form_cls.template_name
return kwargs
def get_form_class(self):
form_class_path = self.get_object().form
form_class = path_to_class(form_class_path)

View File

@ -1,14 +1,14 @@
"""passbook audit models"""
from enum import Enum
from uuid import UUID
from inspect import getmodule, stack
from typing import Optional, Dict, Any
from typing import Any, Dict, Optional
from uuid import UUID
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.http import HttpRequest
from django.utils.translation import gettext as _

View File

@ -0,0 +1,19 @@
# Generated by Django 3.0.3 on 2020-02-17 16:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_core", "0005_merge_20191025_2022"),
]
operations = [
migrations.AddField(
model_name="propertymapping",
name="template",
field=models.TextField(default=""),
preserve_default=False,
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 3.0.3 on 2020-02-17 19:34
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("passbook_core", "0006_propertymapping_template"),
]
operations = [
migrations.RenameField(
model_name="propertymapping", old_name="template", new_name="expression",
),
]

View File

@ -2,15 +2,18 @@
from datetime import timedelta
from random import SystemRandom
from time import sleep
from typing import Optional
from typing import Optional, Any
from uuid import uuid4
from jinja2.nativetypes import NativeEnvironment
from django.contrib.auth.models import AbstractUser
from django.contrib.postgres.fields import JSONField
from django.db import models
from django.urls import reverse_lazy
from django.http import HttpRequest
from django.utils.timezone import now
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from django_prometheus.models import ExportModelOperationsMixin
from guardian.mixins import GuardianUserMixin
from model_utils.managers import InheritanceManager
from structlog import get_logger
@ -21,6 +24,7 @@ from passbook.policies.exceptions import PolicyException
from passbook.policies.struct import PolicyRequest, PolicyResult
LOGGER = get_logger()
NATIVE_ENVIRONMENT = NativeEnvironment()
def default_nonce_duration():
@ -28,7 +32,7 @@ def default_nonce_duration():
return now() + timedelta(hours=4)
class Group(UUIDModel):
class Group(ExportModelOperationsMixin("group"), UUIDModel):
"""Custom Group model which supports a basic hierarchy"""
name = models.CharField(_("name"), max_length=80)
@ -49,7 +53,7 @@ class Group(UUIDModel):
unique_together = (("name", "parent",),)
class User(GuardianUserMixin, AbstractUser):
class User(ExportModelOperationsMixin("user"), GuardianUserMixin, AbstractUser):
"""Custom User model to allow easier adding o f user-based settings"""
uuid = models.UUIDField(default=uuid4, editable=False)
@ -72,7 +76,7 @@ class User(GuardianUserMixin, AbstractUser):
permissions = (("reset_user_password", "Reset Password"),)
class Provider(models.Model):
class Provider(ExportModelOperationsMixin("provider"), models.Model):
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
property_mappings = models.ManyToManyField(
@ -107,7 +111,7 @@ class UserSettings:
self.view_name = view_name
class Factor(PolicyModel):
class Factor(ExportModelOperationsMixin("factor"), PolicyModel):
"""Authentication factor, multiple instances of the same Factor can be used"""
name = models.TextField()
@ -128,7 +132,7 @@ class Factor(PolicyModel):
return f"Factor {self.slug}"
class Application(PolicyModel):
class Application(ExportModelOperationsMixin("application"), PolicyModel):
"""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"""
@ -154,7 +158,7 @@ class Application(PolicyModel):
return self.name
class Source(PolicyModel):
class Source(ExportModelOperationsMixin("source"), PolicyModel):
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
name = models.TextField()
@ -199,7 +203,7 @@ class UserSourceConnection(CreatedUpdatedModel):
unique_together = (("user", "source"),)
class Policy(UUIDModel, CreatedUpdatedModel):
class Policy(ExportModelOperationsMixin("policy"), UUIDModel, CreatedUpdatedModel):
"""Policies which specify if a user is authorized to use an Application. Can be overridden by
other types to add other fields, more logic, etc."""
@ -241,7 +245,7 @@ class DebugPolicy(Policy):
verbose_name_plural = _("Debug Policies")
class Invitation(UUIDModel):
class Invitation(ExportModelOperationsMixin("invitation"), UUIDModel):
"""Single-use invitation link"""
created_by = models.ForeignKey("User", on_delete=models.CASCADE)
@ -266,7 +270,7 @@ class Invitation(UUIDModel):
verbose_name_plural = _("Invitations")
class Nonce(UUIDModel):
class Nonce(ExportModelOperationsMixin("nonce"), UUIDModel):
"""One-time link for password resets/sign-up-confirmations"""
expires = models.DateTimeField(default=default_nonce_duration)
@ -292,10 +296,16 @@ class PropertyMapping(UUIDModel):
"""User-defined key -> x mapping which can be used by providers to expose extra data."""
name = models.TextField()
expression = models.TextField()
form = ""
objects = InheritanceManager()
def evaluate(self, user: User, request: HttpRequest, **kwargs) -> Any:
"""Evaluate `self.expression` using `**kwargs` as Context."""
expression = NATIVE_ENVIRONMENT.from_string(self.expression)
return expression.render(user=user, request=request, **kwargs)
def __str__(self):
return f"Property Mapping {self.name}"

View File

@ -23,7 +23,7 @@ def _redirect_with_qs(view, get_query_set=None):
"""Wrapper to redirect whilst keeping GET Parameters"""
target = reverse(view)
if get_query_set:
target += "?" + urlencode(get_query_set)
target += "?" + urlencode(get_query_set.items())
return redirect(target)

View File

@ -8,7 +8,6 @@ from urllib.parse import urlparse
import yaml
from django.conf import ImproperlyConfigured
from django.utils.autoreload import autoreload_started
from structlog import get_logger
SEARCH_PATHS = ["passbook/lib/default.yml", "/etc/passbook/config.yml", "",] + glob(
@ -142,12 +141,3 @@ class ConfigLoader:
CONFIG = ConfigLoader()
def signal_handler(sender, **_):
"""Add all loaded config files to autoreload watcher"""
for path in CONFIG.loaded_file:
sender.watch_file(path)
autoreload_started.connect(signal_handler)

View File

@ -3,9 +3,9 @@ from hashlib import md5
from urllib.parse import urlencode
from django import template
from django.template import Context
from django.apps import apps
from django.db.models import Model
from django.template import Context
from django.utils.html import escape
from django.utils.translation import ugettext as _

View File

@ -20,6 +20,5 @@ class CreateAssignPermView(CreateView):
self.object._meta.app_label,
self.object._meta.model_name,
)
print(full_permission)
assign_perm(full_permission, self.request.user, self.object)
return response

View File

@ -1,5 +1,5 @@
"""passbook policy engine"""
from multiprocessing import Pipe
from multiprocessing import Pipe, set_start_method
from multiprocessing.connection import Connection
from typing import List, Optional, Tuple
@ -12,6 +12,9 @@ from passbook.policies.process import PolicyProcess, cache_key
from passbook.policies.struct import PolicyRequest, PolicyResult
LOGGER = get_logger()
# This is only really needed for macOS, because Python 3.8 changed the default to spawn
# spawn causes issues with objects that aren't picklable, and also the django setup
set_start_method("fork")
class PolicyProcessInfo:
@ -36,13 +39,15 @@ class PolicyEngine:
policies: List[Policy] = []
request: PolicyRequest
__processes: List[PolicyProcessInfo] = []
__cached_policies: List[PolicyResult]
__processes: List[PolicyProcessInfo]
def __init__(self, policies, user: User, request: HttpRequest = None):
self.policies = policies
self.request = PolicyRequest(user)
if request:
self.request.http_request = request
self.__cached_policies = []
self.__processes = []
def _select_subclasses(self) -> List[Policy]:
@ -55,21 +60,20 @@ class PolicyEngine:
def build(self) -> "PolicyEngine":
"""Build task group"""
cached_policies = []
for policy in self._select_subclasses():
cached_policy = cache.get(cache_key(policy, self.request.user), None)
if cached_policy and self.use_cache:
LOGGER.debug("Taking result from cache", policy=policy)
cached_policies.append(cached_policy)
else:
LOGGER.debug("Evaluating policy", policy=policy)
our_end, task_end = Pipe(False)
task = PolicyProcess(policy, self.request, task_end)
LOGGER.debug("Starting Process", policy=policy)
task.start()
self.__processes.append(
PolicyProcessInfo(process=task, connection=our_end, policy=policy)
)
self.__cached_policies.append(cached_policy)
continue
LOGGER.debug("Evaluating policy", policy=policy)
our_end, task_end = Pipe(False)
task = PolicyProcess(policy, self.request, task_end)
LOGGER.debug("Starting Process", policy=policy)
task.start()
self.__processes.append(
PolicyProcessInfo(process=task, connection=our_end, policy=policy)
)
# If all policies are cached, we have an empty list here.
for proc_info in self.__processes:
proc_info.process.join(proc_info.policy.timeout)
@ -82,13 +86,14 @@ class PolicyEngine:
def result(self) -> Tuple[bool, List[str]]:
"""Get policy-checking result"""
messages: List[str] = []
for proc_info in self.__processes:
LOGGER.debug(
"Result", policy=proc_info.policy, passing=proc_info.result.passing
)
if proc_info.result.messages:
messages += proc_info.result.messages
if not proc_info.result.passing:
process_results: List[PolicyResult] = [
x.result for x in self.__processes if x.result
]
for result in process_results + self.__cached_policies:
LOGGER.debug("result", passing=result.passing)
if result.messages:
messages += result.messages
if not result.passing:
return False, messages
return True, messages

View File

@ -1,7 +1,7 @@
"""passbook Application Security Gateway Forms"""
from django import forms
from oauth2_provider.generators import generate_client_id, generate_client_secret
from oidc_provider.models import Client
from oidc_provider.models import Client, ResponseType
from passbook.providers.app_gw.models import ApplicationGatewayProvider
@ -16,9 +16,14 @@ class ApplicationGatewayProviderForm(forms.ModelForm):
client_id=generate_client_id(), client_secret=generate_client_secret()
)
self.instance.client.name = self.instance.name
self.instance.client.response_types.set(
[ResponseType.objects.get_by_natural_key("code")]
)
self.instance.client.redirect_uris = [
f"http://{self.instance.host}/oauth2/callback",
f"https://{self.instance.host}/oauth2/callback",
f"http://{self.instance.external_host}/oauth2/callback",
f"https://{self.instance.external_host}/oauth2/callback",
f"http://{self.instance.internal_host}/oauth2/callback",
f"https://{self.instance.internal_host}/oauth2/callback",
]
self.instance.client.scope = ["openid", "email"]
self.instance.client.save()
@ -27,8 +32,9 @@ class ApplicationGatewayProviderForm(forms.ModelForm):
class Meta:
model = ApplicationGatewayProvider
fields = ["name", "host"]
fields = ["name", "internal_host", "external_host"]
widgets = {
"name": forms.TextInput(),
"host": forms.TextInput(),
"internal_host": forms.TextInput(),
"external_host": forms.TextInput(),
}

View File

@ -0,0 +1,24 @@
# Generated by Django 2.2.9 on 2020-01-02 15:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_app_gw", "0003_applicationgatewayprovider"),
]
operations = [
migrations.RenameField(
model_name="applicationgatewayprovider",
old_name="host",
new_name="external_host",
),
migrations.AddField(
model_name="applicationgatewayprovider",
name="internal_host",
field=models.TextField(default=""),
preserve_default=False,
),
]

View File

@ -14,7 +14,8 @@ class ApplicationGatewayProvider(Provider):
"""This provider uses oauth2_proxy with the OIDC Provider."""
name = models.TextField()
host = models.TextField()
internal_host = models.TextField()
external_host = models.TextField()
client = models.ForeignKey(Client, on_delete=models.CASCADE)

View File

@ -40,10 +40,10 @@ services:
environment:
OAUTH2_PROXY_CLIENT_ID: {{ provider.client.client_id }}
OAUTH2_PROXY_CLIENT_SECRET: {{ provider.client.client_secret }}
OAUTH2_PROXY_REDIRECT_URL: https://{{ provider.host }}/oauth2/callback
OAUTH2_PROXY_OIDC_ISSUER_URL: https://{{ request.META.host }}/application/oidc
OAUTH2_PROXY_REDIRECT_URL: https://{{ provider.external_host }}/oauth2/callback
OAUTH2_PROXY_OIDC_ISSUER_URL: https://{{ request.META.HTTP_HOST }}/application/oidc
OAUTH2_PROXY_COOKIE_SECRET: {{ cookie_secret }}
OAUTH2_PROXY_UPSTREAM: http://{{ provider.host }}</textarea>
OAUTH2_PROXY_UPSTREAMS: http://{{ provider.internal_host }}</textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-dismiss="modal">{% trans 'Close' %}</button>

View File

@ -1,21 +1,38 @@
"""OIDC Permission checking"""
from typing import Optional
from django.contrib import messages
from django.db.models.deletion import Collector
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from oidc_provider.models import Client
from structlog import get_logger
from passbook.audit.models import Event, EventAction
from passbook.core.models import Application
from passbook.core.models import Application, Provider, User
from passbook.policies.engine import PolicyEngine
LOGGER = get_logger()
def check_permissions(request, user, client):
def check_permissions(
request: HttpRequest, user: User, client: Client
) -> Optional[HttpResponse]:
"""Check permissions, used for
https://django-oidc-provider.readthedocs.io/en/latest/
sections/settings.html#oidc-after-userlogin-hook"""
try:
application = client.openidprovider.application
# because oidc_provider is also used by app_gw, we can't be
# sure an OpenIDPRovider instance exists. hence we look through all related models
# and choose the one that inherits from Provider, which is guaranteed to
# have the application property
collector = Collector(using="default")
collector.collect([client])
for _, related in collector.data.items():
related_object = next(iter(related))
if isinstance(related_object, Provider):
application = related_object.application
break
except Application.DoesNotExist:
return redirect("passbook_providers_oauth:oauth2-permission-denied")
LOGGER.debug(

View File

@ -14,12 +14,16 @@ class SAMLProviderSerializer(ModelSerializer):
fields = [
"pk",
"name",
"property_mappings",
"processor_path",
"acs_url",
"audience",
"processor_path",
"issuer",
"assertion_valid_for",
"assertion_valid_not_before",
"assertion_valid_not_on_or_after",
"session_valid_not_on_or_after",
"property_mappings",
"digest_algorithm",
"signature_algorithm",
"signing",
"signing_cert",
"signing_key",
@ -39,7 +43,7 @@ class SAMLPropertyMappingSerializer(ModelSerializer):
class Meta:
model = SAMLPropertyMapping
fields = ["pk", "name", "saml_name", "friendly_name", "values"]
fields = ["pk", "name", "saml_name", "friendly_name", "expression"]
class SAMLPropertyMappingViewSet(ModelViewSet):

View File

@ -1,336 +0,0 @@
"""Basic SAML Processor"""
import time
import uuid
from defusedxml import ElementTree
from structlog import get_logger
from passbook.providers.saml import exceptions, utils, 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."""
is_idp_initiated = False
_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:persistent"
_system_params = {}
@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 = get_logger()
self._system_params["ISSUER"] = self._remote.issuer
self._logger.debug("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(86400 * 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 = utils.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._remote.audience
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 = utils.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."""
# https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions
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.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,
},
{
"FriendlyName": "uid",
"Name": "urn:oid:0.9.2342.19200300.100.1.1",
"Value": self._django_request.user.pk,
},
]
from passbook.providers.saml.models import SAMLPropertyMapping
for mapping in self._remote.property_mappings.all().select_subclasses():
if isinstance(mapping, SAMLPropertyMapping):
mapping_payload = {
"Name": mapping.saml_name,
"ValueArray": [],
"FriendlyName": mapping.friendly_name,
}
for value in mapping.values:
mapping_payload["ValueArray"].append(
value.format(
user=self._django_request.user, request=self._django_request
)
)
self._assertion_params["ATTRIBUTES"].append(mapping_payload)
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."""
assertion_id = self._assertion_params["ASSERTION_ID"]
self._response_xml = xml_render.get_response_xml(
self._response_params, saml_provider=self._remote, 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": self._remote.application.skip_authorization,
}
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."
)
root = ElementTree.fromstring(self._request_xml)
params = {}
params["ACS_URL"] = root.attrib["AssertionConsumerServiceURL"]
params["REQUEST_ID"] = root.attrib["ID"]
params["DESTINATION"] = root.attrib.get("Destination", "")
params["PROVIDER_NAME"] = root.attrib.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:persistent"
self._system_params = {"ISSUER": self._remote.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."""
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.
# Only call can_handle if SP initiated Request, otherwise we have no Request
if not self.is_idp_initiated:
self.can_handle(self._django_request)
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, url):
"""Initialize this Processor to make an IdP-initiated call to the SP's
deep-linked URL."""
self._reset(request)
acs_url = self._remote.acs_url
# NOTE: The following request params are made up. Some are blank,
# because they comes over in the AuthnRequest, but we don't have an
# AuthnRequest in this case:
# - Destination: Should be this IdP's SSO endpoint URL. Not used in the response?
# - ProviderName: According to the spec, this is optional.
self._request_params = {
"ACS_URL": acs_url,
"DESTINATION": "",
"PROVIDER_NAME": "",
}
self._relay_state = url

View File

@ -4,13 +4,12 @@ from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext as _
from passbook.lib.fields import DynamicArrayField
from passbook.providers.saml.models import (
SAMLPropertyMapping,
SAMLProvider,
get_provider_choices,
)
from passbook.providers.saml.utils import CertificateBuilder
from passbook.providers.saml.utils.cert import CertificateBuilder
class SAMLProviderForm(forms.ModelForm):
@ -32,24 +31,27 @@ class SAMLProviderForm(forms.ModelForm):
model = SAMLProvider
fields = [
"name",
"property_mappings",
"processor_path",
"acs_url",
"audience",
"processor_path",
"issuer",
"assertion_valid_for",
"assertion_valid_not_before",
"assertion_valid_not_on_or_after",
"session_valid_not_on_or_after",
"property_mappings",
"digest_algorithm",
"signature_algorithm",
"signing",
"signing_cert",
"signing_key",
]
labels = {
"acs_url": "ACS URL",
"signing_cert": "Singing Certificate",
}
widgets = {
"name": forms.TextInput(),
"audience": forms.TextInput(),
"issuer": forms.TextInput(),
"assertion_valid_not_before": forms.TextInput(),
"assertion_valid_not_on_or_after": forms.TextInput(),
"session_valid_not_on_or_after": forms.TextInput(),
"property_mappings": FilteredSelectMultiple(_("Property Mappings"), False),
}
@ -57,16 +59,14 @@ class SAMLProviderForm(forms.ModelForm):
class SAMLPropertyMappingForm(forms.ModelForm):
"""SAML Property Mapping form"""
template_name = "saml/idp/property_mapping_form.html"
class Meta:
model = SAMLPropertyMapping
fields = ["name", "saml_name", "friendly_name", "values"]
fields = ["name", "saml_name", "friendly_name", "expression"]
widgets = {
"name": forms.TextInput(),
"saml_name": forms.TextInput(),
"friendly_name": forms.TextInput(),
}
field_classes = {"values": DynamicArrayField}
help_texts = {
"values": 'String substitution uses a syntax like "{variable} test}".'
}

View File

@ -0,0 +1,61 @@
# Generated by Django 2.2.9 on 2020-02-14 13:54
from django.db import migrations, models
import passbook.providers.saml.utils.time
def migrate_valid_for(apps, schema_editor):
"""Migrate from single number standing for minutes to 'minutes=3'"""
SAMLProvider = apps.get_model("passbook_providers_saml", "SAMLProvider")
db_alias = schema_editor.connection.alias
for provider in SAMLProvider.objects.using(db_alias).all():
provider.assertion_valid_not_on_or_after = (
f"minutes={provider.assertion_valid_for}"
)
provider.save()
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_saml", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="samlprovider",
name="assertion_valid_not_before",
field=models.TextField(
default="minutes=5",
help_text="Assertion valid not before current time - this value (Format: hours=1;minutes=2;seconds=3).",
validators=[
passbook.providers.saml.utils.time.timedelta_string_validator
],
),
),
migrations.AddField(
model_name="samlprovider",
name="assertion_valid_not_on_or_after",
field=models.TextField(
default="minutes=5",
help_text="Assertion not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).",
validators=[
passbook.providers.saml.utils.time.timedelta_string_validator
],
),
),
migrations.RunPython(migrate_valid_for),
migrations.RemoveField(model_name="samlprovider", name="assertion_valid_for",),
migrations.AddField(
model_name="samlprovider",
name="session_valid_not_on_or_after",
field=models.TextField(
default="minutes=86400",
help_text="Session not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).",
validators=[
passbook.providers.saml.utils.time.timedelta_string_validator
],
),
),
]

View File

@ -0,0 +1,38 @@
# Generated by Django 2.2.9 on 2020-02-16 11:09
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_saml", "0002_auto_20200214_1354"),
]
operations = [
migrations.AlterField(
model_name="samlpropertymapping",
name="saml_name",
field=models.TextField(verbose_name="SAML Name"),
),
migrations.AlterField(
model_name="samlpropertymapping",
name="values",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(),
help_text="This string can contain string substitutions delimited by {}. The following Variables are available: user, request",
size=None,
),
),
migrations.AlterField(
model_name="samlprovider",
name="acs_url",
field=models.URLField(verbose_name="ACS URL"),
),
migrations.AlterField(
model_name="samlprovider",
name="signing_cert",
field=models.TextField(verbose_name="Singing Certificate"),
),
]

View File

@ -0,0 +1,41 @@
# Generated by Django 3.0.3 on 2020-02-17 15:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_saml", "0003_auto_20200216_1109"),
]
operations = [
migrations.AddField(
model_name="samlprovider",
name="digest_algorithm",
field=models.CharField(
choices=[("sha1", "SHA1"), ("sha256", "SHA256")],
default="sha256",
max_length=50,
),
),
migrations.AddField(
model_name="samlprovider",
name="signature_algorithm",
field=models.CharField(
choices=[
("rsa-sha1", "RSA-SHA1"),
("rsa-sha256", "RSA-SHA256"),
("ecdsa-sha256", "ECDSA-SHA256"),
("dsa-sha1", "DSA-SHA1"),
],
default="rsa-sha256",
max_length=50,
),
),
migrations.AlterField(
model_name="samlprovider",
name="processor_path",
field=models.CharField(choices=[], max_length=255),
),
]

View File

@ -0,0 +1,76 @@
# Generated by Django 3.0.3 on 2020-02-17 16:15
from django.db import migrations
def cleanup_old_autogenerated(apps, schema_editor):
SAMLPropertyMapping = apps.get_model(
"passbook_providers_saml", "SAMLPropertyMapping"
)
db_alias = schema_editor.connection.alias
SAMLPropertyMapping.objects.using(db_alias).filter(
name__startswith="Autogenerated"
).delete()
def create_default_property_mappings(apps, schema_editor):
"""Create default SAML Property Mappings"""
SAMLPropertyMapping = apps.get_model(
"passbook_providers_saml", "SAMLPropertyMapping"
)
db_alias = schema_editor.connection.alias
defaults = [
{
"FriendlyName": "eduPersonPrincipalName",
"Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
"Expression": "{{ user.email }}",
},
{
"FriendlyName": "cn",
"Name": "urn:oid:2.5.4.3",
"Expression": "{{ user.name }}",
},
{
"FriendlyName": "mail",
"Name": "urn:oid:0.9.2342.19200300.100.1.3",
"Expression": "{{ user.email }}",
},
{
"FriendlyName": "displayName",
"Name": "urn:oid:2.16.840.1.113730.3.1.241",
"Expression": "{{ user.username }}",
},
{
"FriendlyName": "uid",
"Name": "urn:oid:0.9.2342.19200300.100.1.1",
"Expression": "{{ user.pk }}",
},
{
"FriendlyName": "member-of",
"Name": "member-of",
"Expression": "[{% for group in user.groups.all() %}'{{ group.name }}',{% endfor %}]",
},
]
for default in defaults:
SAMLPropertyMapping.objects.using(db_alias).get_or_create(
saml_name=default["Name"],
friendly_name=default["FriendlyName"],
expression=default["Expression"],
defaults={
"name": f"Autogenerated SAML Mapping: {default['FriendlyName']} -> {default['Expression']}"
},
)
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_saml", "0004_auto_20200217_1526"),
("passbook_core", "0007_auto_20200217_1934"),
]
operations = [
migrations.RunPython(cleanup_old_autogenerated),
migrations.RemoveField(model_name="samlpropertymapping", name="values",),
migrations.RunPython(create_default_property_mappings),
]

View File

@ -1,13 +1,13 @@
"""passbook saml_idp Models"""
from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.shortcuts import reverse
from django.utils.translation import gettext as _
from django.utils.translation import ugettext_lazy as _
from structlog import get_logger
from passbook.core.models import PropertyMapping, Provider
from passbook.lib.utils.reflection import class_to_path, path_to_class
from passbook.providers.saml.base import Processor
from passbook.providers.saml.processors.base import Processor
from passbook.providers.saml.utils.time import timedelta_string_validator
LOGGER = get_logger()
@ -16,13 +16,62 @@ class SAMLProvider(Provider):
"""Model to save information about a Remote SAML Endpoint"""
name = models.TextField()
acs_url = models.URLField()
audience = models.TextField(default="")
processor_path = models.CharField(max_length=255, choices=[])
acs_url = models.URLField(verbose_name=_("ACS URL"))
audience = models.TextField(default="")
issuer = models.TextField()
assertion_valid_for = models.IntegerField(default=86400)
assertion_valid_not_before = models.TextField(
default="minutes=5",
validators=[timedelta_string_validator],
help_text=_(
(
"Assertion valid not before current time - this value "
"(Format: hours=1;minutes=2;seconds=3)."
)
),
)
assertion_valid_not_on_or_after = models.TextField(
default="minutes=5",
validators=[timedelta_string_validator],
help_text=_(
(
"Assertion not valid on or after current time + this value "
"(Format: hours=1;minutes=2;seconds=3)."
)
),
)
session_valid_not_on_or_after = models.TextField(
default="minutes=86400",
validators=[timedelta_string_validator],
help_text=_(
(
"Session not valid on or after current time + this value "
"(Format: hours=1;minutes=2;seconds=3)."
)
),
)
digest_algorithm = models.CharField(
max_length=50,
choices=(("sha1", _("SHA1")), ("sha256", _("SHA256")),),
default="sha256",
)
signature_algorithm = models.CharField(
max_length=50,
choices=(
("rsa-sha1", _("RSA-SHA1")),
("rsa-sha256", _("RSA-SHA256")),
("ecdsa-sha256", _("ECDSA-SHA256")),
("dsa-sha1", _("DSA-SHA1")),
),
default="rsa-sha256",
)
signing = models.BooleanField(default=True)
signing_cert = models.TextField()
signing_cert = models.TextField(verbose_name=_("Singing Certificate"))
signing_key = models.TextField()
form = "passbook.providers.saml.forms.SAMLProviderForm"
@ -44,7 +93,7 @@ class SAMLProvider(Provider):
return self._processor
def __str__(self):
return "SAML Provider %s" % self.name
return f"SAML Provider {self.name}"
def link_download_metadata(self):
"""Get link to download XML metadata for admin interface"""
@ -66,14 +115,13 @@ class SAMLProvider(Provider):
class SAMLPropertyMapping(PropertyMapping):
"""SAML Property mapping, allowing Name/FriendlyName mapping to a list of strings"""
saml_name = models.TextField()
saml_name = models.TextField(verbose_name="SAML Name")
friendly_name = models.TextField(default=None, blank=True, null=True)
values = ArrayField(models.TextField())
form = "passbook.providers.saml.forms.SAMLPropertyMappingForm"
def __str__(self):
return "SAML Property Mapping %s" % self.saml_name
return f"SAML Property Mapping {self.saml_name}"
class Meta:

View File

@ -0,0 +1,221 @@
"""Basic SAML Processor"""
from typing import TYPE_CHECKING, Dict, List, Union
from defusedxml import ElementTree
from django.http import HttpRequest
from structlog import get_logger
from passbook.providers.saml.exceptions import CannotHandleAssertion
from passbook.providers.saml.utils import get_random_id
from passbook.providers.saml.utils.encoding import decode_base64_and_inflate, nice64
from passbook.providers.saml.utils.time import get_time_string, timedelta_from_string
from passbook.providers.saml.utils.xml_render import get_assertion_xml, get_response_xml
if TYPE_CHECKING:
from passbook.providers.saml.models import SAMLProvider
# pylint: disable=too-many-instance-attributes
class Processor:
"""Base SAML 2.0 AuthnRequest to Response Processor.
Sub-classes should provide Service Provider-specific functionality."""
is_idp_initiated = False
_remote: "SAMLProvider"
_http_request: HttpRequest
_assertion_xml: str
_response_xml: str
_saml_response: str
_relay_state: str
_saml_request: str
_assertion_params: Dict[str, Union[str, List[Dict[str, str]]]]
_request_params: Dict[str, str]
_response_params: Dict[str, str]
@property
def subject_format(self) -> str:
"""Get subject Format"""
return "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
def __init__(self, remote: "SAMLProvider"):
self.name = remote.name
self._remote = remote
self._logger = get_logger()
def _build_assertion(self):
"""Builds _assertion_params."""
self._assertion_params = {
"ASSERTION_ID": get_random_id(),
"ASSERTION_SIGNATURE": "", # it's unsigned
"AUDIENCE": self._remote.audience,
"AUTH_INSTANT": get_time_string(),
"ISSUE_INSTANT": get_time_string(),
"NOT_BEFORE": get_time_string(
timedelta_from_string(self._remote.assertion_valid_not_before)
),
"NOT_ON_OR_AFTER": get_time_string(
timedelta_from_string(self._remote.assertion_valid_not_on_or_after)
),
"SESSION_INDEX": self._http_request.session.session_key,
"SESSION_NOT_ON_OR_AFTER": get_time_string(
timedelta_from_string(self._remote.session_valid_not_on_or_after)
),
"SP_NAME_QUALIFIER": self._remote.audience,
"SUBJECT": self._http_request.user.email,
"SUBJECT_FORMAT": self.subject_format,
"ISSUER": self._remote.issuer,
}
self._assertion_params.update(self._request_params)
def _build_response(self):
"""Builds _response_params."""
self._response_params = {
"ASSERTION": self._assertion_xml,
"ISSUE_INSTANT": get_time_string(),
"RESPONSE_ID": get_random_id(),
"RESPONSE_SIGNATURE": "", # initially unsigned
"ISSUER": self._remote.issuer,
}
self._response_params.update(self._request_params)
def _encode_response(self):
"""Encodes _response_xml to _encoded_xml."""
self._saml_response = nice64(str.encode(self._response_xml))
def _extract_saml_request(self):
"""Retrieves the _saml_request AuthnRequest from the _http_request."""
self._saml_request = self._http_request.session["SAMLRequest"]
self._relay_state = self._http_request.session["RelayState"]
def _format_assertion(self):
"""Formats _assertion_params as _assertion_xml."""
# https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions
attributes = []
from passbook.providers.saml.models import SAMLPropertyMapping
for mapping in self._remote.property_mappings.all().select_subclasses():
if isinstance(mapping, SAMLPropertyMapping):
value = mapping.evaluate(
user=self._http_request.user,
request=self._http_request,
provider=self._remote,
)
mapping_payload = {
"Name": mapping.saml_name,
"FriendlyName": mapping.friendly_name,
}
if isinstance(value, list):
mapping_payload["ValueArray"] = value
else:
mapping_payload["Value"] = value
attributes.append(mapping_payload)
self._assertion_params["ATTRIBUTES"] = attributes
self._assertion_xml = get_assertion_xml(
"saml/xml/assertions/generic.xml", self._assertion_params, signed=True
)
def _format_response(self):
"""Formats _response_params as _response_xml."""
assertion_id = self._assertion_params["ASSERTION_ID"]
self._response_xml = get_response_xml(
self._response_params, saml_provider=self._remote, assertion_id=assertion_id
)
def _get_django_response_params(self) -> Dict[str, str]:
"""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": self._remote.application.skip_authorization,
}
def _decode_and_parse_request(self):
"""Parses various parameters from _request_xml into _request_params."""
decoded_xml = decode_base64_and_inflate(self._saml_request).decode("utf-8")
root = ElementTree.fromstring(decoded_xml)
params = {}
params["ACS_URL"] = root.attrib.get(
"AssertionConsumerServiceURL", self._remote.acs_url
)
params["REQUEST_ID"] = root.attrib["ID"]
params["DESTINATION"] = root.attrib.get("Destination", "")
params["PROVIDER_NAME"] = root.attrib.get("ProviderName", "")
self._request_params = params
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 = (
f"ACS URL of {request_acs_url} doesn't match Provider "
f"ACS URL of {self._remote.acs_url}."
)
self._logger.info(msg)
raise CannotHandleAssertion(msg)
def can_handle(self, request: HttpRequest) -> bool:
"""Returns true if this processor can handle this request."""
self._http_request = request
# Read the request.
try:
self._extract_saml_request()
except Exception as exc:
raise CannotHandleAssertion(
f"can't find SAML request in user session: {exc}"
) from exc
try:
self._decode_and_parse_request()
except Exception as exc:
raise CannotHandleAssertion(f"can't parse SAML request: {exc}") from exc
self._validate_request()
return True
def generate_response(self) -> Dict[str, str]:
"""Processes request and returns template variables suitable for a response."""
# Build the assertion and response.
# Only call can_handle if SP initiated Request, otherwise we have no Request
if not self.is_idp_initiated:
self.can_handle(self._http_request)
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: HttpRequest, url: str):
"""Initialize this Processor to make an IdP-initiated call to the SP's
deep-linked URL."""
self._http_request = request
acs_url = self._remote.acs_url
# NOTE: The following request params are made up. Some are blank,
# because they comes over in the AuthnRequest, but we don't have an
# AuthnRequest in this case:
# - Destination: Should be this IdP's SSO endpoint URL. Not used in the response?
# - ProviderName: According to the spec, this is optional.
self._request_params = {
"ACS_URL": acs_url,
"DESTINATION": "",
"PROVIDER_NAME": "",
}
self._relay_state = url

View File

@ -1,7 +1,7 @@
"""Generic Processor"""
from passbook.providers.saml.base import Processor
from passbook.providers.saml.processors.base import Processor
class GenericProcessor(Processor):
"""Generic Response Handler Processor for testing against django-saml2-sp."""
"""Generic SAML2 Processor"""

View File

@ -1,16 +1,14 @@
"""Salesforce Processor"""
from passbook.providers.saml.base import Processor
from passbook.providers.saml.xml_render import get_assertion_xml
from passbook.providers.saml.processors.generic import GenericProcessor
from passbook.providers.saml.utils.xml_render import get_assertion_xml
class SalesForceProcessor(Processor):
class SalesForceProcessor(GenericProcessor):
"""SalesForce.com-specific SAML 2.0 AuthnRequest to Response Handler Processor."""
def _determine_audience(self):
self._audience = "IAMShowcase"
def _format_assertion(self):
super()._format_assertion()
self._assertion_xml = get_assertion_xml(
"saml/xml/assertions/salesforce.xml", self._assertion_params, signed=True
)

View File

@ -0,0 +1,20 @@
{% extends "generic/form.html" %}
{% load i18n %}
{% block beneath_form %}
<div class="form-group ">
<label class="col-sm-2 control-label" for="friendly_name-2">
</label>
<div class="col-sm-10">
<p>
Expression using <a href="https://jinja.palletsprojects.com/en/2.11.x/templates/">Jinja</a>. Following variables are available:
<ul>
<li><code>user</code>: Passbook User Object (<a href="https://beryju.github.io/passbook/reference/property-mappings/user-object/">Reference</a>)</li>
<li><code>request</code>: Django HTTP Request Object (<a href="https://docs.djangoproject.com/en/3.0/ref/request-response/#httprequest-objects">Reference</a>) </li>
<li><code>provider</code>: Passbook SAML Provider Object (<a href="https://github.com/BeryJu/passbook/blob/master/passbook/providers/saml/models.py#L16">Reference</a>) </li>
</ul>
</p>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,30 @@
"""Test time utils"""
from datetime import timedelta
from django.core.exceptions import ValidationError
from django.test import TestCase
from passbook.providers.saml.utils.time import (
timedelta_from_string,
timedelta_string_validator,
)
class TestTimeUtils(TestCase):
"""Test time-utils"""
def test_valid(self):
"""Test valid expression"""
expr = "hours=3;minutes=1"
expected = timedelta(hours=3, minutes=1)
self.assertEqual(timedelta_from_string(expr), expected)
def test_invalid(self):
"""Test invalid expression"""
with self.assertRaises(ValueError):
timedelta_from_string("foo")
def test_validation(self):
"""Test Django model field validator"""
with self.assertRaises(ValidationError):
timedelta_string_validator("foo")

View File

@ -0,0 +1,18 @@
"""Small helper functions"""
import uuid
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.template.context import Context
def render_xml(request: HttpRequest, template: str, ctx: Context) -> HttpResponse:
"""Render template with content_type application/xml"""
return render(request, template, context=ctx, content_type="application/xml")
def get_random_id() -> str:
"""Random hex id"""
# It is very important that these random IDs NOT start with a number.
random_id = "_" + uuid.uuid4().hex
return random_id

View File

@ -1,8 +1,6 @@
"""Wrappers to de/encode and de/inflate strings"""
import base64
"""Create self-signed certificates"""
import datetime
import uuid
import zlib
from cryptography import x509
from cryptography.hazmat.backends import default_backend
@ -11,24 +9,6 @@ from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
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", "")
class CertificateBuilder:
"""Build self-signed certificates"""

View File

@ -0,0 +1,24 @@
"""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)
try:
return zlib.decompress(decoded_data, -15)
except zlib.error:
return decoded_data
def deflate_and_base64_encode(string_val):
"""Base64 and ZLib Compress b64string"""
zlibbed_str = zlib.compress(string_val)
compressed_string = zlibbed_str[2:-4]
return base64.b64encode(compressed_string)
def nice64(src):
""" Returns src base64-encoded and formatted nicely for our XML. """
return base64.b64encode(src).decode("utf-8").replace("\n", "")

View File

@ -0,0 +1,47 @@
"""Time utilities"""
import datetime
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
ALLOWED_KEYS = (
"days",
"seconds",
"microseconds",
"milliseconds",
"minutes",
"hours",
"weeks",
)
def timedelta_string_validator(value: str):
"""Validator for Django that checks if value can be parsed with `timedelta_from_string`"""
try:
timedelta_from_string(value)
except ValueError as exc:
raise ValidationError(
_("%(value)s is not in the correct format of 'hours=3;minutes=1'."),
params={"value": value},
) from exc
def timedelta_from_string(expr: str) -> datetime.timedelta:
"""Convert a string with the format of 'hours=1;minute=3;seconds=5' to a
`datetime.timedelta` Object with hours = 1, minutes = 3, seconds = 5"""
kwargs = {}
for duration_pair in expr.split(";"):
key, value = duration_pair.split("=")
if key.lower() not in ALLOWED_KEYS:
continue
kwargs[key.lower()] = float(value)
return datetime.timedelta(**kwargs)
def get_time_string(delta: datetime.timedelta = None) -> str:
"""Get Data formatted in SAML format"""
if delta is None:
delta = datetime.timedelta()
now = datetime.datetime.now()
final = now + delta
return final.strftime("%Y-%m-%dT%H:%M:%SZ")

View File

@ -6,7 +6,10 @@ from typing import TYPE_CHECKING
from structlog import get_logger
from passbook.lib.utils.template import render_to_string
from passbook.providers.saml.xml_signing import get_signature_xml, sign_with_signxml
from passbook.providers.saml.utils.xml_signing import (
get_signature_xml,
sign_with_signxml,
)
if TYPE_CHECKING:
from passbook.providers.saml.models import SAMLProvider
@ -60,7 +63,6 @@ def get_assertion_xml(template, parameters, signed=False):
_get_attribute_statement(params)
unsigned = render_to_string(template, params)
# LOGGER.debug('Unsigned: %s', unsigned)
if not signed:
return unsigned
@ -80,18 +82,11 @@ def get_response_xml(parameters, saml_provider: SAMLProvider, assertion_id=""):
raw_response = render_to_string("saml/xml/response.xml", params)
# LOGGER.debug('Unsigned: %s', unsigned)
if not saml_provider.signing:
return raw_response
signature_xml = get_signature_xml()
params["RESPONSE_SIGNATURE"] = signature_xml
# LOGGER.debug("Raw response: %s", raw_response)
signed = sign_with_signxml(
saml_provider.signing_key,
raw_response,
saml_provider.signing_cert,
reference_uri=assertion_id,
)
signed = sign_with_signxml(raw_response, saml_provider, reference_uri=assertion_id,)
return signed

View File

@ -1,4 +1,6 @@
"""Signing code goes here."""
from typing import TYPE_CHECKING
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from lxml import etree # nosec
@ -7,25 +9,34 @@ from structlog import get_logger
from passbook.lib.utils.template import render_to_string
if TYPE_CHECKING:
from passbook.providers.saml.models import SAMLProvider
LOGGER = get_logger()
def sign_with_signxml(private_key, data, cert, reference_uri=None):
def sign_with_signxml(data: str, provider: "SAMLProvider", reference_uri=None) -> str:
"""Sign Data with signxml"""
key = serialization.load_pem_private_key(
str.encode("\n".join([x.strip() for x in private_key.split("\n")])),
str.encode("\n".join([x.strip() for x in provider.signing_key.split("\n")])),
password=None,
backend=default_backend(),
)
# defused XML is not used here because it messes up XML namespaces
# Data is trusted, so lxml is ok
root = etree.fromstring(data) # nosec
signer = XMLSigner(c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#")
signed = signer.sign(root, key=key, cert=[cert], reference_uri=reference_uri)
XMLVerifier().verify(signed, x509_cert=cert)
signer = XMLSigner(
c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#",
signature_algorithm=provider.signature_algorithm,
digest_algorithm=provider.digest_algorithm,
)
signed = signer.sign(
root, key=key, cert=[provider.signing_cert], reference_uri=reference_uri
)
XMLVerifier().verify(signed, x509_cert=provider.signing_cert)
return etree.tostring(signed).decode("utf-8") # nosec
def get_signature_xml():
def get_signature_xml() -> str:
"""Returns XML Signature for subject."""
return render_to_string("saml/xml/signature.xml", {})

View File

@ -1,9 +1,11 @@
"""passbook SAML IDP Views"""
from typing import Optional
from django.contrib.auth import logout
from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
from django.http import HttpResponse, HttpResponseBadRequest
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render, reverse
from django.utils.datastructures import MultiValueDictKeyError
from django.utils.decorators import method_decorator
@ -25,7 +27,7 @@ LOGGER = get_logger()
URL_VALIDATOR = URLValidator(schemes=("http", "https"))
def _generate_response(request, provider: SAMLProvider):
def _generate_response(request: HttpRequest, provider: SAMLProvider) -> HttpResponse:
"""Generate a SAML response using processor_instance and return it in the proper Django
response."""
try:
@ -39,18 +41,13 @@ def _generate_response(request, provider: SAMLProvider):
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")
class AccessRequiredView(AccessMixin, View):
"""Mixin class for Views using a provider instance"""
_provider = None
_provider: Optional[SAMLProvider] = None
@property
def provider(self):
def provider(self) -> SAMLProvider:
"""Get provider instance"""
if not self._provider:
application = get_object_or_404(
@ -59,15 +56,18 @@ class AccessRequiredView(AccessMixin, View):
self._provider = get_object_or_404(SAMLProvider, pk=application.provider_id)
return self._provider
def _has_access(self):
def _has_access(self) -> bool:
"""Check if user has access to application"""
LOGGER.debug(
"_has_access", user=self.request.user, app=self.provider.application
)
policy_engine = PolicyEngine(
self.provider.application.policies.all(), self.request.user, self.request
)
policy_engine.build()
return policy_engine.passing
def dispatch(self, request, *args, **kwargs):
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
if not request.user.is_authenticated:
return self.handle_no_permission()
if not self._has_access():
@ -87,13 +87,13 @@ class LoginBeginView(AccessRequiredView):
stores it in the session prior to enforcing login."""
@method_decorator(csrf_exempt)
def dispatch(self, request, application):
def dispatch(self, request: HttpRequest, application: str) -> HttpResponse:
if request.method == "POST":
source = request.POST
else:
source = request.GET
# Store these values now, because Django's login cycle won't preserve them.
# Store these values now, because Django's login cycle won't preserve them.
try:
request.session["SAMLRequest"] = source["SAMLRequest"]
except (KeyError, MultiValueDictKeyError):
@ -111,7 +111,9 @@ class LoginBeginView(AccessRequiredView):
class RedirectToSPView(AccessRequiredView):
"""Return autosubmit form"""
def get(self, request, acs_url, saml_response, relay_state):
def get(
self, request: HttpRequest, acs_url: str, saml_response: str, relay_state: str
) -> HttpResponse:
"""Return autosubmit form"""
return render(
request,
@ -128,10 +130,9 @@ class LoginProcessView(AccessRequiredView):
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider."""
# pylint: disable=unused-argument
def get(self, request, application):
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Handle get request, i.e. render form"""
LOGGER.debug("SAMLLoginProcessView", request=request, method="get")
# Check if user has access
# User access gets checked in dispatch
if self.provider.application.skip_authorization:
ctx = self.provider.processor.generate_response()
# Log Application Authorization
@ -147,16 +148,15 @@ class LoginProcessView(AccessRequiredView):
relay_state=ctx["relay_state"],
)
try:
full_res = _generate_response(request, self.provider)
return full_res
return _generate_response(request, self.provider)
except exceptions.CannotHandleAssertion as exc:
LOGGER.debug(exc)
return HttpResponseBadRequest()
# pylint: disable=unused-argument
def post(self, request, application):
def post(self, request: HttpRequest, application: str) -> HttpResponse:
"""Handle post request, return back to ACS"""
LOGGER.debug("SAMLLoginProcessView", request=request, method="post")
# Check if user has access
# User access gets checked in dispatch
if request.POST.get("ACSUrl", None):
# User accepted request
Event.new(
@ -171,10 +171,10 @@ class LoginProcessView(AccessRequiredView):
relay_state=request.POST.get("RelayState"),
)
try:
full_res = _generate_response(request, self.provider)
return full_res
return _generate_response(request, self.provider)
except exceptions.CannotHandleAssertion as exc:
LOGGER.debug(exc)
return HttpResponseBadRequest()
class LogoutView(CSRFExemptMixin, AccessRequiredView):
@ -183,7 +183,7 @@ class LogoutView(CSRFExemptMixin, AccessRequiredView):
though it's technically not SAML 2.0)."""
# pylint: disable=unused-argument
def get(self, request, application):
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Perform logout"""
logout(request)
@ -204,7 +204,7 @@ class SLOLogout(CSRFExemptMixin, AccessRequiredView):
logs out the user and returns a standard logged-out page."""
# pylint: disable=unused-argument
def post(self, request, application):
def post(self, request: HttpRequest, application: str) -> HttpResponse:
"""Perform logout"""
request.session["SAMLRequest"] = request.POST["SAMLRequest"]
# TODO: Parse SAML LogoutRequest from POST data, similar to login_process().
@ -219,7 +219,7 @@ class SLOLogout(CSRFExemptMixin, AccessRequiredView):
class DescriptorDownloadView(AccessRequiredView):
"""Replies with the XML Metadata IDSSODescriptor."""
def get(self, request, application):
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Replies with the XML Metadata IDSSODescriptor."""
entity_id = self.provider.issuer
slo_url = request.build_absolute_uri(
@ -255,7 +255,7 @@ class InitiateLoginView(AccessRequiredView):
"""IdP-initiated Login"""
# pylint: disable=unused-argument
def get(self, request, application):
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Initiates an IdP-initiated link to a simple SP resource/target URL."""
self.provider.processor.init_deep_link(request, "")
self.provider.processor.is_idp_initiated = True

View File

@ -2,7 +2,7 @@
from base64 import b64encode
from django.conf import settings
from django.http import Http404, HttpRequest, HttpResponse
from django.http import HttpRequest, HttpResponse
from django.views import View
from django_prometheus.exports import ExportToDjangoView
@ -13,11 +13,13 @@ class MetricsView(View):
def get(self, request: HttpRequest) -> HttpResponse:
"""Check for HTTP-Basic auth"""
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
auth_type, _, credentials = auth_header.partition(" ")
auth_type, _, given_credentials = auth_header.partition(" ")
credentials = f"monitor:{settings.SECRET_KEY}"
expected = b64encode(str.encode(credentials)).decode()
if auth_type != "Basic" or credentials != expected:
raise Http404
if auth_type != "Basic" or given_credentials != expected:
response = HttpResponse(status=401)
response["WWW-Authenticate"] = 'Basic realm="passbook-monitoring"'
return response
return ExportToDjangoView(request)

View File

@ -35,7 +35,7 @@ for _passbook_app in get_apps():
urlpatterns += [
# Administration
path("administration/django/", admin.site.urls),
path("metrics", MetricsView.as_view(), name="metrics"),
path("metrics/", MetricsView.as_view(), name="metrics"),
]
if settings.DEBUG:

View File

@ -62,7 +62,8 @@ class WSGILogger:
if environ.get("QUERY_STRING") != "":
query_string = f"?{environ.get('QUERY_STRING')}"
self.logger.info(
f"{environ.get('PATH_INFO', '')}{query_string}",
"request",
path=f"{environ.get('PATH_INFO', '')}{query_string}",
host=host,
method=environ.get("REQUEST_METHOD", ""),
protocol=environ.get("SERVER_PROTOCOL", ""),

View File

@ -35,7 +35,7 @@ class LDAPPropertyMappingSerializer(ModelSerializer):
class Meta:
model = LDAPPropertyMapping
fields = ["pk", "name", "ldap_property", "object_field"]
fields = ["pk", "name", "expression", "object_field"]
class LDAPSourceViewSet(ModelViewSet):

View File

@ -6,7 +6,7 @@ import ldap3.core.exceptions
from structlog import get_logger
from passbook.core.models import Group, User
from passbook.sources.ldap.models import LDAPSource
from passbook.sources.ldap.models import LDAPSource, LDAPPropertyMapping
LOGGER = get_logger()
@ -154,7 +154,10 @@ class Connector:
) -> Dict[str, Dict[Any, Any]]:
properties = {"attributes": {}}
for mapping in self._source.property_mappings.all().select_subclasses():
properties[mapping.object_field] = attributes.get(mapping.ldap_property, "")
mapping: LDAPPropertyMapping
properties[mapping.object_field] = mapping.evaluate(
user=None, request=None, ldap=attributes
)
if self._source.object_uniqueness_field in attributes:
properties["attributes"]["ldap_uniq"] = attributes.get(
self._source.object_uniqueness_field

View File

@ -45,23 +45,17 @@ class LDAPSourceForm(forms.ModelForm):
"policies": FilteredSelectMultiple(_("policies"), False),
"property_mappings": FilteredSelectMultiple(_("Property Mappings"), False),
}
labels = {
"server_uri": _("Server URI"),
"bind_cn": _("Bind CN"),
"start_tls": _("Enable Start TLS"),
"base_dn": _("Base DN"),
"additional_user_dn": _("Addition User DN"),
"additional_group_dn": _("Addition Group DN"),
}
class LDAPPropertyMappingForm(forms.ModelForm):
"""LDAP Property Mapping form"""
template_name = "ldap/property_mapping_form.html"
class Meta:
model = LDAPPropertyMapping
fields = ["name", "ldap_property", "object_field"]
fields = ["name", "object_field", "expression"]
widgets = {
"name": forms.TextInput(),
"ldap_property": forms.TextInput(),

View File

@ -13,8 +13,9 @@ def create_default_ad_property_mappings(apps: Apps, schema_editor):
"sAMAccountName": "username",
"mail": "email",
}
db_alias = schema_editor.connection.alias
for ldap_property, object_field in mapping.items():
LDAPPropertyMapping.objects.get_or_create(
LDAPPropertyMapping.objects.using(db_alias).get_or_create(
ldap_property=ldap_property,
object_field=object_field,
defaults={

View File

@ -0,0 +1,60 @@
# Generated by Django 2.2.9 on 2020-02-16 11:16
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_sources_ldap", "0005_auto_20191011_1059"),
]
operations = [
migrations.AlterField(
model_name="ldappropertymapping",
name="ldap_property",
field=models.TextField(verbose_name="LDAP Property"),
),
migrations.AlterField(
model_name="ldapsource",
name="additional_group_dn",
field=models.TextField(
help_text="Prepended to Base DN for Group-queries.",
verbose_name="Addition Group DN",
),
),
migrations.AlterField(
model_name="ldapsource",
name="additional_user_dn",
field=models.TextField(
help_text="Prepended to Base DN for User-queries.",
verbose_name="Addition User DN",
),
),
migrations.AlterField(
model_name="ldapsource",
name="base_dn",
field=models.TextField(verbose_name="Base DN"),
),
migrations.AlterField(
model_name="ldapsource",
name="bind_cn",
field=models.TextField(verbose_name="Bind CN"),
),
migrations.AlterField(
model_name="ldapsource",
name="server_uri",
field=models.TextField(
validators=[
django.core.validators.URLValidator(schemes=["ldap", "ldaps"])
],
verbose_name="Server URI",
),
),
migrations.AlterField(
model_name="ldapsource",
name="start_tls",
field=models.BooleanField(default=False, verbose_name="Enable Start TLS"),
),
]

View File

@ -0,0 +1,46 @@
# Generated by Django 3.0.3 on 2020-02-17 16:19
from django.apps.registry import Apps
from django.db import migrations
def cleanup_old_autogenerated(apps, schema_editor):
LDAPPropertyMapping = apps.get_model("passbook_sources_ldap", "LDAPPropertyMapping")
db_alias = schema_editor.connection.alias
LDAPPropertyMapping.objects.using(db_alias).filter(
name__startswith="Autogenerated"
).delete()
def create_default_ad_property_mappings(apps: Apps, schema_editor):
LDAPPropertyMapping = apps.get_model("passbook_sources_ldap", "LDAPPropertyMapping")
mapping = {
"name": "{{ ldap.name }}",
"first_name": "{{ ldap.givenName }}",
"last_name": "{{ ldap.sn }}",
"username": "{{ ldap.sAMAccountName }}",
"email": "{{ ldap.mail }}",
}
db_alias = schema_editor.connection.alias
for object_field, expression in mapping.items():
LDAPPropertyMapping.objects.using(db_alias).get_or_create(
expression=expression,
object_field=object_field,
defaults={
"name": f"Autogenerated LDAP Mapping: {expression} -> {object_field}"
},
)
class Migration(migrations.Migration):
dependencies = [
("passbook_sources_ldap", "0006_auto_20200216_1116"),
("passbook_core", "0007_auto_20200217_1934"),
]
operations = [
migrations.RunPython(cleanup_old_autogenerated),
migrations.RemoveField(model_name="ldappropertymapping", name="ldap_property",),
migrations.RunPython(create_default_ad_property_mappings),
]

View File

@ -2,7 +2,7 @@
from django.core.validators import URLValidator
from django.db import models
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from passbook.core.models import Group, PropertyMapping, Source
@ -10,17 +10,22 @@ from passbook.core.models import Group, PropertyMapping, Source
class LDAPSource(Source):
"""LDAP Authentication source"""
server_uri = models.TextField(validators=[URLValidator(schemes=["ldap", "ldaps"])])
bind_cn = models.TextField()
server_uri = models.TextField(
validators=[URLValidator(schemes=["ldap", "ldaps"])],
verbose_name=_("Server URI"),
)
bind_cn = models.TextField(verbose_name=_("Bind CN"))
bind_password = models.TextField()
start_tls = models.BooleanField(default=False)
start_tls = models.BooleanField(default=False, verbose_name=_("Enable Start TLS"))
base_dn = models.TextField()
base_dn = models.TextField(verbose_name=_("Base DN"))
additional_user_dn = models.TextField(
help_text=_("Prepended to Base DN for User-queries.")
help_text=_("Prepended to Base DN for User-queries."),
verbose_name=_("Addition User DN"),
)
additional_group_dn = models.TextField(
help_text=_("Prepended to Base DN for Group-queries.")
help_text=_("Prepended to Base DN for Group-queries."),
verbose_name=_("Addition Group DN"),
)
user_object_filter = models.TextField(
@ -54,13 +59,12 @@ class LDAPSource(Source):
class LDAPPropertyMapping(PropertyMapping):
"""Map LDAP Property to User or Group object"""
ldap_property = models.TextField()
object_field = models.TextField()
form = "passbook.sources.ldap.forms.LDAPPropertyMappingForm"
def __str__(self):
return f"LDAP Property Mapping {self.ldap_property} -> {self.object_field}"
return f"LDAP Property Mapping {self.expression} -> {self.object_field}"
class Meta:

View File

@ -0,0 +1,18 @@
{% extends "generic/form.html" %}
{% load i18n %}
{% block beneath_form %}
<div class="form-group ">
<label class="col-sm-2 control-label" for="friendly_name-2">
</label>
<div class="col-sm-10">
<p>
Expression using <a href="https://jinja.palletsprojects.com/en/2.11.x/templates/">Jinja</a>. Following variables are available:
<ul>
<li><code>ldap</code>: A Dictionary of all values retrieved from LDAP.</li>
</ul>
</p>
</div>
</div>
{% endblock %}

View File

@ -38,12 +38,6 @@ class OAuthSourceForm(forms.ModelForm):
"provider_type": forms.Select(choices=MANAGER.get_name_tuple()),
"policies": FilteredSelectMultiple(_("policies"), False),
}
labels = {
"request_token_url": _("Request Token URL"),
"authorization_url": _("Authorization URL"),
"access_token_url": _("Access Token URL"),
"profile_url": _("Profile URL"),
}
class GitHubOAuthSourceForm(OAuthSourceForm):

View File

@ -0,0 +1,35 @@
# Generated by Django 3.0.3 on 2020-02-17 15:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_sources_oauth", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="oauthsource",
name="access_token_url",
field=models.CharField(max_length=255, verbose_name="Access Token URL"),
),
migrations.AlterField(
model_name="oauthsource",
name="authorization_url",
field=models.CharField(max_length=255, verbose_name="Authorization URL"),
),
migrations.AlterField(
model_name="oauthsource",
name="profile_url",
field=models.CharField(max_length=255, verbose_name="Profile URL"),
),
migrations.AlterField(
model_name="oauthsource",
name="request_token_url",
field=models.CharField(
blank=True, max_length=255, verbose_name="Request Token URL"
),
),
]

View File

@ -2,7 +2,7 @@
from django.db import models
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from passbook.core.models import Source, UserSettings, UserSourceConnection
from passbook.sources.oauth.clients import get_client
@ -12,10 +12,16 @@ class OAuthSource(Source):
"""Configuration for OAuth provider."""
provider_type = models.CharField(max_length=255)
request_token_url = models.CharField(blank=True, max_length=255)
authorization_url = models.CharField(max_length=255)
access_token_url = models.CharField(max_length=255)
profile_url = models.CharField(max_length=255)
request_token_url = models.CharField(
blank=True, max_length=255, verbose_name=_("Request Token URL")
)
authorization_url = models.CharField(
max_length=255, verbose_name=_("Authorization URL")
)
access_token_url = models.CharField(
max_length=255, verbose_name=_("Access Token URL")
)
profile_url = models.CharField(max_length=255, verbose_name=_("Profile URL"))
consumer_key = models.TextField()
consumer_secret = models.TextField()

View File

@ -5,7 +5,7 @@ from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext as _
from passbook.admin.forms.source import SOURCE_FORM_FIELDS
from passbook.providers.saml.utils import CertificateBuilder
from passbook.providers.saml.utils.cert import CertificateBuilder
from passbook.sources.saml.models import SAMLSource
@ -28,11 +28,6 @@ class SAMLSourceForm(forms.ModelForm):
"auto_logout",
"signing_cert",
]
labels = {
"entity_id": "Entity ID",
"idp_url": "IDP URL",
"idp_logout_url": "IDP Logout URL",
}
widgets = {
"name": forms.TextInput(),
"policies": FilteredSelectMultiple(_("policies"), False),

View File

@ -0,0 +1,30 @@
# Generated by Django 3.0.3 on 2020-02-17 15:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_sources_saml", "0003_auto_20191107_1550"),
]
operations = [
migrations.AlterField(
model_name="samlsource",
name="entity_id",
field=models.TextField(blank=True, default=None, verbose_name="Entity ID"),
),
migrations.AlterField(
model_name="samlsource",
name="idp_logout_url",
field=models.URLField(
blank=True, default=None, null=True, verbose_name="IDP Logout URL"
),
),
migrations.AlterField(
model_name="samlsource",
name="idp_url",
field=models.URLField(verbose_name="IDP URL"),
),
]

View File

@ -1,7 +1,7 @@
"""saml sp models"""
from django.db import models
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from passbook.core.models import Source
@ -9,9 +9,11 @@ from passbook.core.models import Source
class SAMLSource(Source):
"""SAML2 Source"""
entity_id = models.TextField(blank=True, default=None)
idp_url = models.URLField()
idp_logout_url = models.URLField(default=None, blank=True, null=True)
entity_id = models.TextField(blank=True, default=None, verbose_name=_("Entity ID"))
idp_url = models.URLField(verbose_name=_("IDP URL"))
idp_logout_url = models.URLField(
default=None, blank=True, null=True, verbose_name=_("IDP Logout URL")
)
auto_logout = models.BooleanField(default=False)
signing_cert = models.TextField()

View File

@ -9,9 +9,9 @@ from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from passbook.providers.saml.base import get_random_id, get_time_string
from passbook.providers.saml.utils import nice64
from passbook.providers.saml.views import render_xml
from passbook.providers.saml.utils import get_random_id, render_xml
from passbook.providers.saml.utils.encoding import nice64
from passbook.providers.saml.utils.time import get_time_string
from passbook.sources.saml.models import SAMLSource
from passbook.sources.saml.utils import (
_get_user_from_response,

View File

@ -2,7 +2,7 @@
from structlog import get_logger
from passbook.lib.utils.template import render_to_string
from passbook.providers.saml.xml_signing import get_signature_xml
from passbook.providers.saml.utils.xml_signing import get_signature_xml
LOGGER = get_logger()

View File

@ -1,4 +1,4 @@
FROM python:3.7-slim-buster as locker
FROM python:3.8-slim-buster as locker
COPY ./Pipfile /app/
COPY ./Pipfile.lock /app/
@ -9,7 +9,7 @@ RUN pip install pipenv && \
pipenv lock -r > requirements.txt && \
pipenv lock -rd > requirements-dev.txt
FROM python:3.7-slim-buster as static-build
FROM python:3.8-slim-buster as static-build
COPY --from=locker /app/requirements.txt /app/
COPY --from=locker /app/requirements-dev.txt /app/
@ -34,8 +34,7 @@ ENV PASSBOOK_POSTGRESQL__USER=passbook
ENV PASSBOOK_POSTGRESQL__PASSWORD="EK-5jnKfjrGRm<77"
RUN ./manage.py collectstatic --no-input
FROM docker.beryju.org/pixie/server
FROM beryju/pixie:latest
COPY --from=static-build /app/static /data/static/
COPY --from=static-build /app/static/robots.txt /data/robots.txt
WORKDIR /data
COPY --from=static-build /app/static /web-root/static/
COPY --from=static-build /app/static/robots.txt /web-root/robots.txt