Merge branch 'master' into pf4

# Conflicts:
#	passbook/core/static/img/logos/discord.svg
#	passbook/core/static/js/passbook.js
#	passbook/core/templates/login/with_sources.html
#	passbook/core/templates/overview/index.html
#	passbook/core/views/authentication.py
This commit is contained in:
Jens Langhammer 2020-02-21 09:05:40 +01:00
commit d88283a7a9
52 changed files with 446 additions and 329 deletions

View file

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

View file

@ -16,11 +16,11 @@ jobs:
- name: Building Docker Image - name: Building Docker Image
run: docker build run: docker build
--no-cache --no-cache
-t beryju/passbook:0.8.1-beta -t beryju/passbook:0.8.5-beta
-t beryju/passbook:latest -t beryju/passbook:latest
-f Dockerfile . -f Dockerfile .
- name: Push Docker Container to Registry (versioned) - name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook:0.8.1-beta run: docker push beryju/passbook:0.8.5-beta
- name: Push Docker Container to Registry (latest) - name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook:latest run: docker push beryju/passbook:latest
build-gatekeeper: build-gatekeeper:
@ -37,11 +37,11 @@ jobs:
cd gatekeeper cd gatekeeper
docker build \ docker build \
--no-cache \ --no-cache \
-t beryju/passbook-gatekeeper:0.8.1-beta \ -t beryju/passbook-gatekeeper:0.8.5-beta \
-t beryju/passbook-gatekeeper:latest \ -t beryju/passbook-gatekeeper:latest \
-f Dockerfile . -f Dockerfile .
- name: Push Docker Container to Registry (versioned) - name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-gatekeeper:0.8.1-beta run: docker push beryju/passbook-gatekeeper:0.8.5-beta
- name: Push Docker Container to Registry (latest) - name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook-gatekeeper:latest run: docker push beryju/passbook-gatekeeper:latest
build-static: build-static:
@ -66,11 +66,11 @@ jobs:
run: docker build run: docker build
--no-cache --no-cache
--network=$(docker network ls | grep github | awk '{print $1}') --network=$(docker network ls | grep github | awk '{print $1}')
-t beryju/passbook-static:0.8.1-beta -t beryju/passbook-static:0.8.5-beta
-t beryju/passbook-static:latest -t beryju/passbook-static:latest
-f static.Dockerfile . -f static.Dockerfile .
- name: Push Docker Container to Registry (versioned) - name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-static:0.8.1-beta run: docker push beryju/passbook-static:0.8.5-beta
- name: Push Docker Container to Registry (latest) - name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook-static:latest run: docker push beryju/passbook-static:latest
test-release: test-release:

View file

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

View file

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

View file

@ -2,6 +2,9 @@
"""Django manage.py""" """Django manage.py"""
import os import os
import sys import sys
from defusedxml import defuse_stdlib
defuse_stdlib()
if __name__ == '__main__': if __name__ == '__main__':
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'passbook.root.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'passbook.root.settings')

View file

@ -1,2 +1,2 @@
"""passbook""" """passbook"""
__version__ = "0.8.1-beta" __version__ = "0.8.5-beta"

View file

@ -36,7 +36,7 @@
<tr> <tr>
<td>{{ source.name }}</td> <td>{{ source.name }}</td>
<td>{{ source|fieldtype }}</td> <td>{{ source|fieldtype }}</td>
<td>{{ source.additional_info|safe }}</td> <td>{{ source.ui_additional_info|safe|default:"" }}</td>
<td> <td>
<a class="btn btn-default btn-sm" <a class="btn btn-default btn-sm"
href="{% url 'passbook_admin:source-update' pk=source.uuid %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a> href="{% url 'passbook_admin:source-update' pk=source.uuid %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>

View file

@ -15,11 +15,13 @@ class ApplicationSerializer(ModelSerializer):
"pk", "pk",
"name", "name",
"slug", "slug",
"launch_url",
"icon_url",
"provider",
"policies",
"skip_authorization", "skip_authorization",
"provider",
"meta_launch_url",
"meta_icon_url",
"meta_description",
"meta_publisher",
"policies",
] ]

View file

@ -1,5 +1,6 @@
"""passbook core exceptions""" """passbook core exceptions"""
from passbook.lib.sentry import SentryIgnoredException
class PropertyMappingExpressionException(Exception): class PropertyMappingExpressionException(SentryIgnoredException):
"""Error when a PropertyMapping Exception expression could not be parsed or evaluated.""" """Error when a PropertyMapping Exception expression could not be parsed or evaluated."""

View file

@ -19,19 +19,25 @@ class ApplicationForm(forms.ModelForm):
fields = [ fields = [
"name", "name",
"slug", "slug",
"launch_url",
"icon_url",
"provider",
"policies",
"skip_authorization", "skip_authorization",
"provider",
"meta_launch_url",
"meta_icon_url",
"meta_description",
"meta_publisher",
"policies",
] ]
widgets = { widgets = {
"name": forms.TextInput(), "name": forms.TextInput(),
"launch_url": forms.TextInput(), "meta_launch_url": forms.TextInput(),
"icon_url": forms.TextInput(), "meta_icon_url": forms.TextInput(),
"meta_publisher": forms.TextInput(),
"policies": FilteredSelectMultiple(_("policies"), False), "policies": FilteredSelectMultiple(_("policies"), False),
} }
labels = { labels = {
"launch_url": _("Launch URL"), "meta_launch_url": _("Launch URL"),
"icon_url": _("Icon URL"), "meta_icon_url": _("Icon URL"),
"meta_description": _("Description"),
"meta_publisher": _("Publisher"),
} }
help_texts = {"policies": _("Policies required to access this Application.")}

View file

@ -0,0 +1,29 @@
# Generated by Django 3.0.3 on 2020-02-20 12:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_core", "0007_auto_20200217_1934"),
]
operations = [
migrations.RenameField(
model_name="application", old_name="icon_url", new_name="meta_icon_url",
),
migrations.RenameField(
model_name="application", old_name="launch_url", new_name="meta_launch_url",
),
migrations.AddField(
model_name="application",
name="meta_description",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="application",
name="meta_publisher",
field=models.TextField(blank=True, null=True),
),
]

View file

@ -23,9 +23,10 @@ from structlog import get_logger
from passbook.core.exceptions import PropertyMappingExpressionException from passbook.core.exceptions import PropertyMappingExpressionException
from passbook.core.signals import password_changed from passbook.core.signals import password_changed
from passbook.core.types import UILoginButton, UIUserSettings
from passbook.lib.models import CreatedUpdatedModel, UUIDModel from passbook.lib.models import CreatedUpdatedModel, UUIDModel
from passbook.policies.exceptions import PolicyException from passbook.policies.exceptions import PolicyException
from passbook.policies.struct import PolicyRequest, PolicyResult from passbook.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger() LOGGER = get_logger()
NATIVE_ENVIRONMENT = NativeEnvironment() NATIVE_ENVIRONMENT = NativeEnvironment()
@ -102,19 +103,6 @@ class PolicyModel(UUIDModel, CreatedUpdatedModel):
policies = models.ManyToManyField("Policy", blank=True) policies = models.ManyToManyField("Policy", blank=True)
class UserSettings:
"""Dataclass for Factor and Source's user_settings"""
name: str
icon: str
view_name: str
def __init__(self, name: str, icon: str, view_name: str):
self.name = name
self.icon = icon
self.view_name = view_name
class Factor(ExportModelOperationsMixin("factor"), PolicyModel): class Factor(ExportModelOperationsMixin("factor"), PolicyModel):
"""Authentication factor, multiple instances of the same Factor can be used""" """Authentication factor, multiple instances of the same Factor can be used"""
@ -127,9 +115,10 @@ class Factor(ExportModelOperationsMixin("factor"), PolicyModel):
type = "" type = ""
form = "" form = ""
def user_settings(self) -> Optional[UserSettings]: @property
def ui_user_settings(self) -> Optional[UIUserSettings]:
"""Entrypoint to integrate with User settings. Can either return None if no """Entrypoint to integrate with User settings. Can either return None if no
user settings are available, or an instanace of UserSettings.""" user settings are available, or an instanace of UIUserSettings."""
return None return None
def __str__(self): def __str__(self):
@ -143,16 +132,19 @@ class Application(ExportModelOperationsMixin("application"), PolicyModel):
name = models.TextField() name = models.TextField()
slug = models.SlugField() slug = models.SlugField()
launch_url = models.URLField(null=True, blank=True) skip_authorization = models.BooleanField(default=False)
icon_url = models.TextField(null=True, blank=True)
provider = models.OneToOneField( provider = models.OneToOneField(
"Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT "Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT
) )
skip_authorization = models.BooleanField(default=False)
meta_launch_url = models.URLField(null=True, blank=True)
meta_icon_url = models.TextField(null=True, blank=True)
meta_description = models.TextField(null=True, blank=True)
meta_publisher = models.TextField(null=True, blank=True)
objects = InheritanceManager() objects = InheritanceManager()
def get_provider(self): def get_provider(self) -> Optional[Provider]:
"""Get casted provider instance""" """Get casted provider instance"""
if not self.provider: if not self.provider:
return None return None
@ -167,6 +159,7 @@ class Source(ExportModelOperationsMixin("source"), PolicyModel):
name = models.TextField() name = models.TextField()
slug = models.SlugField() slug = models.SlugField()
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)
property_mappings = models.ManyToManyField( property_mappings = models.ManyToManyField(
"PropertyMapping", default=None, blank=True "PropertyMapping", default=None, blank=True
@ -177,19 +170,20 @@ class Source(ExportModelOperationsMixin("source"), PolicyModel):
objects = InheritanceManager() objects = InheritanceManager()
@property @property
def login_button(self): def ui_login_button(self) -> Optional[UILoginButton]:
"""Return a tuple of URL, Icon name and Name """If source uses a http-based flow, return UI Information about the login
if Source should get a link on the login page""" button. If source doesn't use http-based flow, return None."""
return None return None
@property @property
def additional_info(self): def ui_additional_info(self) -> Optional[str]:
"""Return additional Info, such as a callback URL. Show in the administration interface.""" """Return additional Info, such as a callback URL. Show in the administration interface."""
return None return None
def user_settings(self) -> Optional[UserSettings]: @property
def ui_user_settings(self) -> Optional[UIUserSettings]:
"""Entrypoint to integrate with User settings. Can either return None if no """Entrypoint to integrate with User settings. Can either return None if no
user settings are available, or an instanace of UserSettings.""" user settings are available, or an instanace of UIUserSettings."""
return None return None
def __str__(self): def __str__(self):

View file

@ -1,46 +1,53 @@
"""passbook user settings template tags""" """passbook user settings template tags"""
from typing import List from typing import Iterable, List
from django import template from django import template
from django.template.context import RequestContext from django.template.context import RequestContext
from passbook.core.models import Factor, Source, UserSettings from passbook.core.models import Factor, Source
from passbook.core.types import UIUserSettings
from passbook.policies.engine import PolicyEngine from passbook.policies.engine import PolicyEngine
register = template.Library() register = template.Library()
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def user_factors(context: RequestContext) -> List[UserSettings]: def user_factors(context: RequestContext) -> List[UIUserSettings]:
"""Return list of all factors which apply to user""" """Return list of all factors which apply to user"""
user = context.get("request").user user = context.get("request").user
_all_factors = ( _all_factors: Iterable[Factor] = (
Factor.objects.filter(enabled=True).order_by("order").select_subclasses() Factor.objects.filter(enabled=True).order_by("order").select_subclasses()
) )
matching_factors: List[UserSettings] = [] matching_factors: List[UIUserSettings] = []
for factor in _all_factors: for factor in _all_factors:
user_settings = factor.user_settings() user_settings = factor.ui_user_settings
if not user_settings:
continue
policy_engine = PolicyEngine( policy_engine = PolicyEngine(
factor.policies.all(), user, context.get("request") factor.policies.all(), user, context.get("request")
) )
policy_engine.build() policy_engine.build()
if policy_engine.passing and user_settings: if policy_engine.passing:
matching_factors.append(user_settings) matching_factors.append(user_settings)
return matching_factors return matching_factors
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def user_sources(context: RequestContext) -> List[UserSettings]: def user_sources(context: RequestContext) -> List[UIUserSettings]:
"""Return a list of all sources which are enabled for the user""" """Return a list of all sources which are enabled for the user"""
user = context.get("request").user user = context.get("request").user
_all_sources = Source.objects.filter(enabled=True).select_subclasses() _all_sources: Iterable[Source] = (
matching_sources: List[UserSettings] = [] Source.objects.filter(enabled=True).select_subclasses()
)
matching_sources: List[UIUserSettings] = []
for factor in _all_sources: for factor in _all_sources:
user_settings = factor.user_settings() user_settings = factor.ui_user_settings
if not user_settings:
continue
policy_engine = PolicyEngine( policy_engine = PolicyEngine(
factor.policies.all(), user, context.get("request") factor.policies.all(), user, context.get("request")
) )
policy_engine.build() policy_engine.build()
if policy_engine.passing and user_settings: if policy_engine.passing:
matching_sources.append(user_settings) matching_sources.append(user_settings)
return matching_sources return matching_sources

29
passbook/core/types.py Normal file
View file

@ -0,0 +1,29 @@
"""passbook core dataclasses"""
from dataclasses import dataclass
from typing import Optional
@dataclass
class UIUserSettings:
"""Dataclass for Factor and Source's user_settings"""
name: str
icon: str
view_name: str
@dataclass
class UILoginButton:
"""Dataclass for Source's ui_ui_login_button"""
# Name, ran through i18n
name: str
# URL Which Button points to
url: str
# Icon name, ran through django's static
icon_path: Optional[str] = None
# Icon URL, used as-is
icon_url: Optional[str] = None

View file

@ -47,9 +47,9 @@ class LoginView(UserPassesTestMixin, FormView):
kwargs["sources"] = [] kwargs["sources"] = []
sources = Source.objects.filter(enabled=True).select_subclasses() sources = Source.objects.filter(enabled=True).select_subclasses()
for source in sources: for source in sources:
login_button = source.login_button if ui_login_button:
if login_button: kwargs["sources"].append(ui_login_button)
kwargs["sources"].append(login_button) ui_login_button = source.ui_login_button
# if kwargs["sources"]: # if kwargs["sources"]:
# self.template_name = "login/with_sources.html" # self.template_name = "login/with_sources.html"
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)

View file

@ -1,9 +1,9 @@
"""OTP Factor""" """OTP Factor"""
from django.db import models from django.db import models
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from passbook.core.models import Factor, UserSettings from passbook.core.models import Factor
from passbook.core.types import UIUserSettings
class OTPFactor(Factor): class OTPFactor(Factor):
@ -17,9 +17,12 @@ class OTPFactor(Factor):
type = "passbook.factors.otp.factors.OTPFactor" type = "passbook.factors.otp.factors.OTPFactor"
form = "passbook.factors.otp.forms.OTPFactorForm" form = "passbook.factors.otp.forms.OTPFactorForm"
def user_settings(self) -> UserSettings: @property
return UserSettings( def ui_user_settings(self) -> UIUserSettings:
_("OTP"), "pficon-locked", "passbook_factors_otp:otp-user-settings" return UIUserSettings(
name="OTP",
icon="pficon-locked",
view_name="passbook_factors_otp:otp-user-settings",
) )
def __str__(self): def __str__(self):

View file

@ -1,7 +1,8 @@
"""passbook password policy exceptions""" """passbook password policy exceptions"""
from passbook.lib.sentry import SentryIgnoredException
class PasswordPolicyInvalid(Exception): class PasswordPolicyInvalid(SentryIgnoredException):
"""Exception raised when a Password Policy fails""" """Exception raised when a Password Policy fails"""
messages = [] messages = []

View file

@ -3,7 +3,8 @@ from django.contrib.postgres.fields import ArrayField
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from passbook.core.models import Factor, Policy, User, UserSettings from passbook.core.models import Factor, Policy, User
from passbook.core.types import UIUserSettings
class PasswordFactor(Factor): class PasswordFactor(Factor):
@ -18,9 +19,12 @@ class PasswordFactor(Factor):
type = "passbook.factors.password.factor.PasswordFactor" type = "passbook.factors.password.factor.PasswordFactor"
form = "passbook.factors.password.forms.PasswordFactorForm" form = "passbook.factors.password.forms.PasswordFactorForm"
def user_settings(self): @property
return UserSettings( def ui_user_settings(self) -> UIUserSettings:
_("Change Password"), "pficon-key", "passbook_core:user-change-password" return UIUserSettings(
name="Change Password",
icon="pficon-key",
view_name="passbook_core:user-change-password",
) )
def password_passes(self, user: User) -> bool: def password_passes(self, user: User) -> bool:

View file

@ -4,6 +4,10 @@ from structlog import get_logger
LOGGER = get_logger() LOGGER = get_logger()
class SentryIgnoredException(Exception):
"""Base Class for all errors that are supressed, and not sent to sentry."""
def before_send(event, hint): def before_send(event, hint):
"""Check if error is database error, and ignore if so""" """Check if error is database error, and ignore if so"""
from django_redis.exceptions import ConnectionInterrupted from django_redis.exceptions import ConnectionInterrupted
@ -29,6 +33,7 @@ def before_send(event, hint):
ValidationError, ValidationError,
OSError, OSError,
RedisError, RedisError,
SentryIgnoredException,
) )
if "exc_info" in hint: if "exc_info" in hint:
_exc_type, exc_value, _ = hint["exc_info"] _exc_type, exc_value, _ = hint["exc_info"]

View file

@ -9,7 +9,7 @@ from structlog import get_logger
from passbook.core.models import Policy, User from passbook.core.models import Policy, User
from passbook.policies.process import PolicyProcess, cache_key from passbook.policies.process import PolicyProcess, cache_key
from passbook.policies.struct import PolicyRequest, PolicyResult from passbook.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger() LOGGER = get_logger()
# This is only really needed for macOS, because Python 3.8 changed the default to spawn # This is only really needed for macOS, because Python 3.8 changed the default to spawn
@ -63,13 +63,13 @@ class PolicyEngine:
for policy in self._select_subclasses(): for policy in self._select_subclasses():
cached_policy = cache.get(cache_key(policy, self.request.user), None) cached_policy = cache.get(cache_key(policy, self.request.user), None)
if cached_policy and self.use_cache: if cached_policy and self.use_cache:
LOGGER.debug("Taking result from cache", policy=policy) LOGGER.debug("P_ENG: Taking result from cache", policy=policy)
self.__cached_policies.append(cached_policy) self.__cached_policies.append(cached_policy)
continue continue
LOGGER.debug("Evaluating policy", policy=policy) LOGGER.debug("P_ENG: Evaluating policy", policy=policy)
our_end, task_end = Pipe(False) our_end, task_end = Pipe(False)
task = PolicyProcess(policy, self.request, task_end) task = PolicyProcess(policy, self.request, task_end)
LOGGER.debug("Starting Process", policy=policy) LOGGER.debug("P_ENG: Starting Process", policy=policy)
task.start() task.start()
self.__processes.append( self.__processes.append(
PolicyProcessInfo(process=task, connection=our_end, policy=policy) PolicyProcessInfo(process=task, connection=our_end, policy=policy)
@ -90,7 +90,7 @@ class PolicyEngine:
x.result for x in self.__processes if x.result x.result for x in self.__processes if x.result
] ]
for result in process_results + self.__cached_policies: for result in process_results + self.__cached_policies:
LOGGER.debug("result", passing=result.passing) LOGGER.debug("P_ENG: result", passing=result.passing)
if result.messages: if result.messages:
messages += result.messages messages += result.messages
if not result.passing: if not result.passing:

View file

@ -1,5 +1,6 @@
"""policy exceptions""" """policy exceptions"""
from passbook.lib.sentry import SentryIgnoredException
class PolicyException(Exception): class PolicyException(SentryIgnoredException):
"""Exception that should be raised during Policy Evaluation, and can be recovered from.""" """Exception that should be raised during Policy Evaluation, and can be recovered from."""

View file

@ -7,7 +7,7 @@ from django.utils.translation import gettext as _
from structlog import get_logger from structlog import get_logger
from passbook.core.models import Policy from passbook.core.models import Policy
from passbook.policies.struct import PolicyRequest, PolicyResult from passbook.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger() LOGGER = get_logger()

View file

@ -9,7 +9,7 @@ from jinja2.nativetypes import NativeEnvironment
from structlog import get_logger from structlog import get_logger
from passbook.factors.view import AuthenticationView from passbook.factors.view import AuthenticationView
from passbook.policies.struct import PolicyRequest, PolicyResult from passbook.policies.types import PolicyRequest, PolicyResult
if TYPE_CHECKING: if TYPE_CHECKING:
from passbook.core.models import User from passbook.core.models import User
@ -76,7 +76,7 @@ class Evaluator:
src=expression_source, src=expression_source,
req=request, req=request,
) )
return PolicyRequest(False) return PolicyResult(False)
if isinstance(result, list) and len(result) == 2: if isinstance(result, list) and len(result) == 2:
return PolicyResult(*result) return PolicyResult(*result)
if result: if result:

View file

@ -4,7 +4,7 @@ from django.utils.translation import gettext as _
from passbook.core.models import Policy from passbook.core.models import Policy
from passbook.policies.expression.evaluator import Evaluator from passbook.policies.expression.evaluator import Evaluator
from passbook.policies.struct import PolicyRequest, PolicyResult from passbook.policies.types import PolicyRequest, PolicyResult
class ExpressionPolicy(Policy): class ExpressionPolicy(Policy):

View file

@ -6,7 +6,7 @@ from django.utils.translation import gettext as _
from structlog import get_logger from structlog import get_logger
from passbook.core.models import Policy from passbook.core.models import Policy
from passbook.policies.struct import PolicyRequest, PolicyResult from passbook.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger() LOGGER = get_logger()

View file

@ -7,7 +7,7 @@ from structlog import get_logger
from passbook.core.models import Policy from passbook.core.models import Policy
from passbook.policies.exceptions import PolicyException from passbook.policies.exceptions import PolicyException
from passbook.policies.struct import PolicyRequest, PolicyResult from passbook.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger() LOGGER = get_logger()

View file

@ -4,7 +4,7 @@ from django.utils.translation import gettext as _
from passbook.core.models import Policy, User from passbook.core.models import Policy, User
from passbook.lib.utils.http import get_client_ip from passbook.lib.utils.http import get_client_ip
from passbook.policies.struct import PolicyRequest, PolicyResult from passbook.policies.types import PolicyRequest, PolicyResult
class ReputationPolicy(Policy): class ReputationPolicy(Policy):

View file

@ -3,7 +3,7 @@ from django.db import models
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from passbook.core.models import Policy from passbook.core.models import Policy
from passbook.policies.struct import PolicyRequest, PolicyResult from passbook.policies.types import PolicyRequest, PolicyResult
class WebhookPolicy(Policy): class WebhookPolicy(Policy):

View file

@ -1,5 +1,6 @@
"""passbook SAML IDP Exceptions""" """passbook SAML IDP Exceptions"""
from passbook.lib.sentry import SentryIgnoredException
class CannotHandleAssertion(Exception): class CannotHandleAssertion(SentryIgnoredException):
"""This processor does not handle this assertion.""" """This processor does not handle this assertion."""

View file

@ -40,7 +40,7 @@ class Processor:
@property @property
def subject_format(self) -> str: def subject_format(self) -> str:
"""Get subject Format""" """Get subject Format"""
return "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" return "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
def __init__(self, remote: "SAMLProvider"): def __init__(self, remote: "SAMLProvider"):
self.name = remote.name self.name = remote.name
@ -144,7 +144,7 @@ class Processor:
def _decode_and_parse_request(self): def _decode_and_parse_request(self):
"""Parses various parameters from _request_xml into _request_params.""" """Parses various parameters from _request_xml into _request_params."""
decoded_xml = decode_base64_and_inflate(self._saml_request).decode("utf-8") decoded_xml = decode_base64_and_inflate(self._saml_request)
root = ElementTree.fromstring(decoded_xml) root = ElementTree.fromstring(decoded_xml)
@ -183,15 +183,13 @@ class Processor:
# Read the request. # Read the request.
try: try:
self._extract_saml_request() self._extract_saml_request()
except KeyError as exc: except KeyError:
raise CannotHandleAssertion( raise CannotHandleAssertion(f"Couldn't find SAML request in user session:")
f"can't find SAML request in user session: {exc}"
) from exc
try: try:
self._decode_and_parse_request() self._decode_and_parse_request()
except Exception as exc: except Exception as exc:
raise CannotHandleAssertion(f"can't parse SAML request: {exc}") from exc raise CannotHandleAssertion(f"Couldn't parse SAML request: {exc}") from exc
self._validate_request() self._validate_request()
return True return True

View file

@ -15,26 +15,9 @@
</ds:X509Data> </ds:X509Data>
</ds:KeyInfo> </ds:KeyInfo>
</md:KeyDescriptor> </md:KeyDescriptor>
<md:NameIDFormat>{{ subject_format }}</md:NameIDFormat>
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ slo_url }}"/> <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:persistent</md:NameIDFormat> <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{{ sso_post_url }}"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ sso_url }}"/> <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ sso_redirect_url }}"/>
</md:IDPSSODescriptor> </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> </md:EntityDescriptor>

View file

@ -13,7 +13,7 @@ urlpatterns = [
# This view is the endpoint a SP would redirect to, and saves data into the session # This view is the endpoint a SP would redirect to, and saves data into the session
# this is required as the process view which it redirects to might have to login first. # this is required as the process view which it redirects to might have to login first.
path( path(
"<slug:application>/login/", views.LoginProcessView.as_view(), name="saml-login" "<slug:application>/login/", views.LoginBeginView.as_view(), name="saml-login"
), ),
path( path(
"<slug:application>/login/process/", "<slug:application>/login/process/",

View file

@ -3,20 +3,20 @@ import base64
import zlib import zlib
def decode_base64_and_inflate(b64string): def decode_base64_and_inflate(encoded: str, encoding="utf-8") -> str:
"""Base64 decode and ZLib decompress b64string""" """Base64 decode and ZLib decompress b64string"""
decoded_data = base64.b64decode(b64string) decoded_data = base64.b64decode(encoded)
try: try:
return zlib.decompress(decoded_data, -15) return zlib.decompress(decoded_data, -15).decode(encoding)
except zlib.error: except zlib.error:
return decoded_data return decoded_data.decode(encoding)
def deflate_and_base64_encode(string_val): def deflate_and_base64_encode(inflated: bytes, encoding="utf-8"):
"""Base64 and ZLib Compress b64string""" """Base64 and ZLib Compress b64string"""
zlibbed_str = zlib.compress(string_val) zlibbed_str = zlib.compress(inflated)
compressed_string = zlibbed_str[2:-4] compressed_string = zlibbed_str[2:-4]
return base64.b64encode(compressed_string) return base64.b64encode(compressed_string).decode(encoding)
def nice64(src): def nice64(src):

View file

@ -17,7 +17,7 @@ from signxml.util import strip_pem_header
from structlog import get_logger from structlog import get_logger
from passbook.audit.models import Event, EventAction from passbook.audit.models import Event, EventAction
from passbook.core.models import Application from passbook.core.models import Application, Provider
from passbook.lib.utils.template import render_to_string from passbook.lib.utils.template import render_to_string
from passbook.lib.views import bad_request_message from passbook.lib.views import bad_request_message
from passbook.policies.engine import PolicyEngine from passbook.policies.engine import PolicyEngine
@ -134,9 +134,7 @@ class LoginProcessView(AccessRequiredView):
try: try:
# application.skip_authorization is set so we directly redirect the user # application.skip_authorization is set so we directly redirect the user
if self.provider.application.skip_authorization: if self.provider.application.skip_authorization:
self.provider.processor.can_handle(request) return self.post(request, application)
saml_params = self.provider.processor.generate_response()
return self.handle_redirect(saml_params, True)
self.provider.processor.init_deep_link(request) self.provider.processor.init_deep_link(request)
params = self.provider.processor.generate_response() params = self.provider.processor.generate_response()
@ -233,7 +231,7 @@ class DescriptorDownloadView(AccessRequiredView):
kwargs={"application": provider.application.slug}, kwargs={"application": provider.application.slug},
) )
) )
sso_url = request.build_absolute_uri( sso_post_url = request.build_absolute_uri(
reverse( reverse(
"passbook_providers_saml:saml-login", "passbook_providers_saml:saml-login",
kwargs={"application": provider.application.slug}, kwargs={"application": provider.application.slug},
@ -242,18 +240,28 @@ class DescriptorDownloadView(AccessRequiredView):
pubkey = strip_pem_header(provider.signing_cert.replace("\r", "")).replace( pubkey = strip_pem_header(provider.signing_cert.replace("\r", "")).replace(
"\n", "" "\n", ""
) )
subject_format = provider.processor.subject_format
ctx = { ctx = {
"entity_id": entity_id, "entity_id": entity_id,
"cert_public_key": pubkey, "cert_public_key": pubkey,
"slo_url": slo_url, "slo_url": slo_url,
"sso_url": sso_url, # Currently, the same endpoint accepts POST and REDIRECT
"sso_post_url": sso_post_url,
"sso_redirect_url": sso_post_url,
"subject_format": subject_format,
} }
return render_to_string("saml/xml/metadata.xml", ctx) return render_to_string("saml/xml/metadata.xml", ctx)
# pylint: disable=unused-argument # pylint: disable=unused-argument
def get(self, request: HttpRequest, application: str) -> HttpResponse: def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Replies with the XML Metadata IDSSODescriptor.""" """Replies with the XML Metadata IDSSODescriptor."""
try:
metadata = DescriptorDownloadView.get_metadata(request, self.provider) metadata = DescriptorDownloadView.get_metadata(request, self.provider)
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
return bad_request_message(
request, "Provider is not assigned to an application."
)
else:
response = HttpResponse(metadata, content_type="application/xml") response = HttpResponse(metadata, content_type="application/xml")
response["Content-Disposition"] = ( response["Content-Disposition"] = (
'attachment; filename="' '%s_passbook_meta.xml"' % self.provider.name 'attachment; filename="' '%s_passbook_meta.xml"' % self.provider.name

View file

@ -49,10 +49,15 @@ LOGIN_URL = "passbook_core:auth-login"
# Custom user model # Custom user model
AUTH_USER_MODEL = "passbook_core.User" AUTH_USER_MODEL = "passbook_core.User"
if DEBUG:
CSRF_COOKIE_NAME = "passbook_csrf_debug"
LANGUAGE_COOKIE_NAME = "passbook_language_debug"
SESSION_COOKIE_NAME = "passbook_session_debug"
else:
CSRF_COOKIE_NAME = "passbook_csrf" CSRF_COOKIE_NAME = "passbook_csrf"
LANGUAGE_COOKIE_NAME = "passbook_language"
SESSION_COOKIE_NAME = "passbook_session" SESSION_COOKIE_NAME = "passbook_session"
SESSION_COOKIE_DOMAIN = CONFIG.y("domain", None) SESSION_COOKIE_DOMAIN = CONFIG.y("domain", None)
LANGUAGE_COOKIE_NAME = "passbook_language"
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
@ -271,6 +276,7 @@ STATIC_URL = "/static/"
structlog.configure_once( structlog.configure_once(
processors=[ processors=[
structlog.stdlib.add_log_level, structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.PositionalArgumentsFormatter(), structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(), structlog.processors.TimeStamper(),
structlog.processors.StackInfoRenderer(), structlog.processors.StackInfoRenderer(),

View file

@ -9,12 +9,14 @@ https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/
import os import os
from time import time from time import time
from defusedxml import defuse_stdlib
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
from structlog import get_logger from structlog import get_logger
from passbook.lib.utils.http import _get_client_ip_from_meta from passbook.lib.utils.http import _get_client_ip_from_meta
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "passbook.root.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "passbook.root.settings")
defuse_stdlib()
class WSGILogger: class WSGILogger:

View file

@ -3,8 +3,8 @@ from typing import Any, Dict, Optional
import ldap3 import ldap3
import ldap3.core.exceptions import ldap3.core.exceptions
from structlog import get_logger
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from structlog import get_logger
from passbook.core.exceptions import PropertyMappingExpressionException from passbook.core.exceptions import PropertyMappingExpressionException
from passbook.core.models import Group, User from passbook.core.models import Group, User

View file

@ -4,7 +4,8 @@ from django.db import models
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from passbook.core.models import Source, UserSettings, UserSourceConnection from passbook.core.models import Source, UserSourceConnection
from passbook.core.types import UILoginButton, UIUserSettings
from passbook.sources.oauth.clients import get_client from passbook.sources.oauth.clients import get_client
@ -28,30 +29,35 @@ class OAuthSource(Source):
form = "passbook.sources.oauth.forms.OAuthSourceForm" form = "passbook.sources.oauth.forms.OAuthSourceForm"
@property @property
def login_button(self): def ui_login_button(self) -> UILoginButton:
return UILoginButton(
url=reverse_lazy( url=reverse_lazy(
"passbook_sources_oauth:oauth-client-login", "passbook_sources_oauth:oauth-client-login",
kwargs={"source_slug": self.slug}, kwargs={"source_slug": self.slug},
),
icon_path=f"img/logos/{self.provider_type}.svg",
name=self.name,
) )
return url, self.provider_type, self.name
@property @property
def additional_info(self): def ui_additional_info(self) -> str:
return "Callback URL: <pre>%s</pre>" % reverse_lazy( url = reverse_lazy(
"passbook_sources_oauth:oauth-client-callback", "passbook_sources_oauth:oauth-client-callback",
kwargs={"source_slug": self.slug}, kwargs={"source_slug": self.slug},
) )
return f"Callback URL: <pre>{url}</pre>"
def user_settings(self) -> UserSettings: @property
def ui_user_settings(self) -> UIUserSettings:
icon_type = self.provider_type icon_type = self.provider_type
if icon_type == "azure ad": if icon_type == "azure ad":
icon_type = "windows" icon_type = "windows"
icon_class = "fa fa-%s" % icon_type icon_class = f"fa fa-{icon_type}"
view_name = "passbook_sources_oauth:oauth-client-user" view_name = "passbook_sources_oauth:oauth-client-user"
return UserSettings( return UIUserSettings(
self.name, name=self.name,
icon_class, icon=icon_class,
reverse((view_name), kwargs={"source_slug": self.slug}), view_name=reverse((view_name), kwargs={"source_slug": self.slug}),
) )
class Meta: class Meta:

View file

@ -13,7 +13,7 @@ class SAMLSourceSerializer(ModelSerializer):
model = SAMLSource model = SAMLSource
fields = [ fields = [
"pk", "pk",
"entity_id", "issuer",
"idp_url", "idp_url",
"idp_logout_url", "idp_logout_url",
"auto_logout", "auto_logout",

View file

@ -0,0 +1,10 @@
"""passbook saml source exceptions"""
from passbook.lib.sentry import SentryIgnoredException
class MissingSAMLResponse(SentryIgnoredException):
"""Exception raised when request does not contain SAML Response."""
class UnsupportedNameIDFormat(SentryIgnoredException):
"""Exception raised when SAML Response contains NameID Format not supported."""

View file

@ -22,7 +22,7 @@ class SAMLSourceForm(forms.ModelForm):
model = SAMLSource model = SAMLSource
fields = SOURCE_FORM_FIELDS + [ fields = SOURCE_FORM_FIELDS + [
"entity_id", "issuer",
"idp_url", "idp_url",
"idp_logout_url", "idp_logout_url",
"auto_logout", "auto_logout",
@ -31,7 +31,7 @@ class SAMLSourceForm(forms.ModelForm):
widgets = { widgets = {
"name": forms.TextInput(), "name": forms.TextInput(),
"policies": FilteredSelectMultiple(_("policies"), False), "policies": FilteredSelectMultiple(_("policies"), False),
"entity_id": forms.TextInput(), "issuer": forms.TextInput(),
"idp_url": forms.TextInput(), "idp_url": forms.TextInput(),
"idp_logout_url": forms.TextInput(), "idp_logout_url": forms.TextInput(),
} }

View file

@ -0,0 +1,26 @@
# Generated by Django 3.0.3 on 2020-02-20 16:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_sources_saml", "0004_auto_20200217_1526"),
]
operations = [
migrations.RenameField(
model_name="samlsource", old_name="entity_id", new_name="issuer",
),
migrations.AlterField(
model_name="samlsource",
name="issuer",
field=models.TextField(
blank=True,
default=None,
help_text="Also known as Entity ID. Defaults the Metadata URL.",
verbose_name="Issuer",
),
),
]

View file

@ -4,12 +4,19 @@ from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from passbook.core.models import Source from passbook.core.models import Source
from passbook.core.types import UILoginButton
class SAMLSource(Source): class SAMLSource(Source):
"""SAML2 Source""" """SAML Source"""
issuer = models.TextField(
blank=True,
default=None,
verbose_name=_("Issuer"),
help_text=_("Also known as Entity ID. Defaults the Metadata URL."),
)
entity_id = models.TextField(blank=True, default=None, verbose_name=_("Entity ID"))
idp_url = models.URLField(verbose_name=_("IDP URL")) idp_url = models.URLField(verbose_name=_("IDP URL"))
idp_logout_url = models.URLField( idp_logout_url = models.URLField(
default=None, blank=True, null=True, verbose_name=_("IDP Logout URL") default=None, blank=True, null=True, verbose_name=_("IDP Logout URL")
@ -20,16 +27,19 @@ class SAMLSource(Source):
form = "passbook.sources.saml.forms.SAMLSourceForm" form = "passbook.sources.saml.forms.SAMLSourceForm"
@property @property
def login_button(self): def ui_login_button(self) -> UILoginButton:
return UILoginButton(
name=self.name,
url=reverse_lazy( url=reverse_lazy(
"passbook_sources_saml:login", kwargs={"source_slug": self.slug} "passbook_sources_saml:login", kwargs={"source_slug": self.slug}
),
icon_path="",
) )
return url, "", self.name
@property @property
def additional_info(self): def ui_additional_info(self) -> str:
metadata_url = reverse_lazy( metadata_url = reverse_lazy(
"passbook_sources_saml:metadata", kwargs={"source_slug": self} "passbook_sources_saml:metadata", kwargs={"source_slug": self.slug}
) )
return f'<a href="{metadata_url}" class="btn btn-default btn-sm">Metadata Download</a>' return f'<a href="{metadata_url}" class="btn btn-default btn-sm">Metadata Download</a>'

View file

@ -0,0 +1,86 @@
"""passbook saml source processor"""
from typing import TYPE_CHECKING, Optional
from defusedxml import ElementTree
from django.http import HttpRequest
from signxml import XMLVerifier
from structlog import get_logger
from passbook.core.models import User
from passbook.providers.saml.utils.encoding import decode_base64_and_inflate
from passbook.sources.saml.exceptions import (
MissingSAMLResponse,
UnsupportedNameIDFormat,
)
from passbook.sources.saml.models import SAMLSource
LOGGER = get_logger()
if TYPE_CHECKING:
from xml.etree.ElementTree import Element # nosec
class Processor:
"""SAML Response Processor"""
_source: SAMLSource
_root: "Element"
_root_xml: str
def __init__(self, source: SAMLSource):
self._source = source
def parse(self, request: HttpRequest):
"""Check if `request` contains SAML Response data, parse and validate it."""
# First off, check if we have any SAML Data at all.
raw_response = request.POST.get("SAMLResponse", None)
if not raw_response:
raise MissingSAMLResponse("Request does not contain 'SAMLResponse'")
# relay_state = request.POST.get('RelayState', None)
# Check if response is compressed, b64 decode it
self._root_xml = decode_base64_and_inflate(raw_response)
self._root = ElementTree.fromstring(self._root_xml)
# Verify signed XML
self._verify_signed()
def _verify_signed(self):
"""Verify SAML Response's Signature"""
verifier = XMLVerifier()
verifier.verify(self._root_xml, x509_cert=self._source.signing_cert)
def _get_email(self) -> Optional[str]:
"""
Returns the email out of the response.
At present, response must pass the email address as the Subject, eg.:
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
SPNameQualifier=""
>email@example.com</saml:NameID>
"""
assertion = self._root.find("{urn:oasis:names:tc:SAML:2.0:assertion}Assertion")
subject = assertion.find("{urn:oasis:names:tc:SAML:2.0:assertion}Subject")
name_id = subject.find("{urn:oasis:names:tc:SAML:2.0:assertion}NameID")
name_id_format = name_id.attrib["Format"]
if name_id_format != "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress":
raise UnsupportedNameIDFormat(
f"Assertion contains NameID with unsupported format {name_id_format}."
)
return name_id.text
def get_user(self) -> User:
"""
Gets info out of the response and locally logs in this user.
May create a local user account first.
Returns the user object that was created.
"""
email = self._get_email()
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
user = User.objects.create_user(username=email, email=email)
# TODO: Property Mappings
user.set_unusable_password()
user.save()
return user

View file

@ -0,0 +1,22 @@
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="{{ issuer }}">
<md:SPSSODescriptor 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:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:AssertionConsumerService isDefault="true" index="0" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{{ acs_url }}"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>

View file

@ -1,70 +0,0 @@
<md:EntityDescriptor
xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
entityID="{{ entity_id }}">
<md:SPSSODescriptor
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:NameIDFormat>
urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
</md:NameIDFormat>
<md:AssertionConsumerService isDefault="true" index="0"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="{{ acs_url }}"/>
{% comment %}
<!-- Other bits that we might need. -->
<!-- Ref: saml-metadata-2.0-os.pdf, pg 10, section 2.3... -->
<md:NameIDFormat>
urn:oasis:names:tc:SAML:2.0:nameid-format:transient
</md:NameIDFormat>
<md:ArtifactResolutionService isDefault="true" index="0"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP"
Location="https://sp.example.com/SAML2/ArtifactResolution"/>
<md:AssertionConsumerService index="1"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact"
Location="https://sp.example.com/SAML2/Artifact"/>
<md:AttributeConsumingService isDefault="true" index="1">
<md:ServiceName xml:lang="en">
Service Provider Portal
</md:ServiceName>
<md:RequestedAttribute
NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.1"
FriendlyName="eduPersonAffiliation">
</md:RequestedAttribute>
</md:AttributeConsumingService>
{% endcomment %}
</md:SPSSODescriptor>
{% comment %}
<!-- #TODO: Add support for optional Organization section -->
{# if org #}
<md:Organization>
<md:OrganizationName xml:lang="en">{{ org.name }}</md:OrganizationName>
<md:OrganizationDisplayName xml:lang="en">{{ org.display_name }}</md:OrganizationDisplayName>
<md:OrganizationURL xml:lang="en">{{ org.url }}</md:OrganizationURL>
</md:Organization>
{# endif #}
<!-- #TODO: Add support for optional ContactPerson section(s) -->
{# for contact in contacts #}
<md:ContactPerson contactType="{{ contact.type }}">
<md:GivenName>{{ contact.given_name }}</md:GivenName>
<md:SurName>{{ contact.sur_name }}</md:SurName>
<md:EmailAddress>{{ contact.email }}</md:EmailAddress>
</md:ContactPerson>
{# endfor #}
{% endcomment %}
</md:EntityDescriptor>

View file

@ -2,82 +2,19 @@
from django.http import HttpRequest from django.http import HttpRequest
from django.shortcuts import reverse from django.shortcuts import reverse
from passbook.core.models import User
from passbook.sources.saml.models import SAMLSource from passbook.sources.saml.models import SAMLSource
def get_entity_id(request: HttpRequest, source: SAMLSource): def get_issuer(request: HttpRequest, source: SAMLSource) -> str:
"""Get Source's entity ID, falling back to our Metadata URL if none is set""" """Get Source's Issuer, falling back to our Metadata URL if none is set"""
entity_id = source.entity_id issuer = source.issuer
if entity_id is None: if issuer is None:
return build_full_url("metadata", request, source) return build_full_url("metadata", request, source)
return entity_id return issuer
def build_full_url(view: str, request: HttpRequest, source: SAMLSource) -> str: def build_full_url(view: str, request: HttpRequest, source: SAMLSource) -> str:
"""Build Full ACS URL to be used in IDP""" """Build Full ACS URL to be used in IDP"""
return request.build_absolute_uri( return request.build_absolute_uri(
reverse(f"passbook_sources_saml:{view}", kwargs={"source": source.slug}) reverse(f"passbook_sources_saml:{view}", kwargs={"source_slug": source.slug})
) )
def _get_email_from_response(root):
"""
Returns the email out of the response.
At present, response must pass the email address as the Subject, eg.:
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:email"
SPNameQualifier=""
>email@example.com</saml:NameID>
"""
assertion = root.find("{urn:oasis:names:tc:SAML:2.0:assertion}Assertion")
subject = assertion.find("{urn:oasis:names:tc:SAML:2.0:assertion}Subject")
name_id = subject.find("{urn:oasis:names:tc:SAML:2.0:assertion}NameID")
return name_id.text
def _get_attributes_from_response(root):
"""
Returns the SAML Attributes (if any) that are present in the response.
NOTE: Technically, attribute values could be any XML structure.
But for now, just assume a single string value.
"""
flat_attributes = {}
assertion = root.find("{urn:oasis:names:tc:SAML:2.0:assertion}Assertion")
attributes = assertion.find(
"{urn:oasis:names:tc:SAML:2.0:assertion}AttributeStatement"
)
for attribute in attributes.getchildren():
name = attribute.attrib.get("Name")
children = attribute.getchildren()
if not children:
# Ignore empty-valued attributes. (I think these are not allowed.)
continue
if len(children) == 1:
# See NOTE:
flat_attributes[name] = children[0].text
else:
# It has multiple values.
for child in children:
# See NOTE:
flat_attributes.setdefault(name, []).append(child.text)
return flat_attributes
def _get_user_from_response(root):
"""
Gets info out of the response and locally logs in this user.
May create a local user account first.
Returns the user object that was created.
"""
email = _get_email_from_response(root)
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
user = User.objects.create_user(username=email, email=email)
user.set_unusable_password()
user.save()
return user

View file

@ -1,23 +1,23 @@
"""saml sp views""" """saml sp views"""
import base64
from defusedxml import ElementTree
from django.contrib.auth import login, logout from django.contrib.auth import login, logout
from django.http import Http404, HttpRequest, HttpResponse from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render, reverse from django.shortcuts import get_object_or_404, redirect, render, reverse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views import View from django.views import View
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from signxml.util import strip_pem_header
from passbook.lib.views import bad_request_message
from passbook.providers.saml.utils import get_random_id, 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.encoding import nice64
from passbook.providers.saml.utils.time import get_time_string from passbook.providers.saml.utils.time import get_time_string
from passbook.sources.saml.models import SAMLSource from passbook.sources.saml.exceptions import (
from passbook.sources.saml.utils import ( MissingSAMLResponse,
_get_user_from_response, UnsupportedNameIDFormat,
build_full_url,
get_entity_id,
) )
from passbook.sources.saml.models import SAMLSource
from passbook.sources.saml.processors.base import Processor
from passbook.sources.saml.utils import build_full_url, get_issuer
from passbook.sources.saml.xml_render import get_authnrequest_xml from passbook.sources.saml.xml_render import get_authnrequest_xml
@ -36,7 +36,7 @@ class InitiateView(View):
"DESTINATION": source.idp_url, "DESTINATION": source.idp_url,
"AUTHN_REQUEST_ID": get_random_id(), "AUTHN_REQUEST_ID": get_random_id(),
"ISSUE_INSTANT": get_time_string(), "ISSUE_INSTANT": get_time_string(),
"ISSUER": get_entity_id(request, source), "ISSUER": get_issuer(request, source),
} }
authn_req = get_authnrequest_xml(parameters, signed=False) authn_req = get_authnrequest_xml(parameters, signed=False)
_request = nice64(str.encode(authn_req)) _request = nice64(str.encode(authn_req))
@ -61,14 +61,18 @@ class ACSView(View):
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug) source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
if not source.enabled: if not source.enabled:
raise Http404 raise Http404
# sso_session = request.POST.get('RelayState', None) processor = Processor(source)
data = request.POST.get("SAMLResponse", None) try:
response = base64.b64decode(data) processor.parse(request)
root = ElementTree.fromstring(response) except MissingSAMLResponse as exc:
user = _get_user_from_response(root) return bad_request_message(request, str(exc))
# attributes = _get_attributes_from_response(root)
try:
user = processor.get_user()
login(request, user, backend="django.contrib.auth.backends.ModelBackend") login(request, user, backend="django.contrib.auth.backends.ModelBackend")
return redirect(reverse("passbook_core:overview")) return redirect(reverse("passbook_core:overview"))
except UnsupportedNameIDFormat as exc:
return bad_request_message(request, str(exc))
class SLOView(View): class SLOView(View):
@ -96,13 +100,16 @@ class MetadataView(View):
def dispatch(self, request: HttpRequest, source_slug: str) -> HttpResponse: def dispatch(self, request: HttpRequest, source_slug: str) -> HttpResponse:
"""Replies with the XML Metadata SPSSODescriptor.""" """Replies with the XML Metadata SPSSODescriptor."""
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug) source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
entity_id = get_entity_id(request, source) issuer = get_issuer(request, source)
cert_stripped = strip_pem_header(source.signing_cert.replace("\r", "")).replace(
"\n", ""
)
return render_xml( return render_xml(
request, request,
"saml/sp/xml/spssodescriptor.xml", "saml/sp/xml/sp_sso_descriptor.xml",
{ {
"acs_url": build_full_url("acs", request, source), "acs_url": build_full_url("acs", request, source),
"entity_id": entity_id, "issuer": issuer,
"cert_public_key": source.signing_cert, "cert_public_key": cert_stripped,
}, },
) )

View file

@ -15,7 +15,6 @@ def get_authnrequest_xml(parameters, signed=False):
params["AUTHN_REQUEST_SIGNATURE"] = "" params["AUTHN_REQUEST_SIGNATURE"] = ""
unsigned = render_to_string("saml/sp/xml/authn_request.xml", params) unsigned = render_to_string("saml/sp/xml/authn_request.xml", params)
LOGGER.debug("AuthN Request", unsigned=unsigned)
if not signed: if not signed:
return unsigned return unsigned
@ -24,5 +23,4 @@ def get_authnrequest_xml(parameters, signed=False):
params["AUTHN_REQUEST_SIGNATURE"] = signature_xml params["AUTHN_REQUEST_SIGNATURE"] = signature_xml
signed = render_to_string("saml/sp/xml/authn_request.xml", params) signed = render_to_string("saml/sp/xml/authn_request.xml", params)
LOGGER.debug("AuthN Request", signed=signed)
return signed return signed

View file

@ -1,5 +1,7 @@
#!/bin/bash -xe #!/bin/bash -xe
isort -rc passbook
black passbook black passbook
scripts/coverage.sh scripts/coverage.sh
bandit -r passbook
pylint passbook pylint passbook
prospector prospector