diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 053830a60..2a8006716 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -116,6 +116,18 @@ build-passbook-static: services: - postgres:latest - redis:latest +build-passbook-gatekeeper: + stage: build + image: + name: gcr.io/kaniko-project/executor:debug + entrypoint: [""] + before_script: + - echo "{\"auths\":{\"docker.beryju.org\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json + script: + - /kaniko/executor --context $CI_PROJECT_DIR/gatekeeper --dockerfile $CI_PROJECT_DIR/gatekeeper/Dockerfile --destination docker.beryju.org/passbook/gatekeeper:latest --destination docker.beryju.org/passbook/gatekeeper:0.7.2-beta + only: + - tags + - /^version/.*$/ package-helm: image: debian:stretch-slim diff --git a/gatekeeper/Dockerfile b/gatekeeper/Dockerfile new file mode 100644 index 000000000..ff9e1c10a --- /dev/null +++ b/gatekeeper/Dockerfile @@ -0,0 +1,8 @@ +FROM quay.io/pusher/oauth2_proxy + +COPY templates /templates + +ENV OAUTH2_PROXY_EMAIL_DOMAINS=* +ENV OAUTH2_PROXY_PROVIDER=oidc +ENV OAUTH2_PROXY_CUSTOM_TEMPLATES_DIR=/templates +ENV OAUTH2_PROXY_HTTP_ADDRESS=:4180 diff --git a/gatekeeper/templates/error.html b/gatekeeper/templates/error.html new file mode 100644 index 000000000..1ad82648e --- /dev/null +++ b/gatekeeper/templates/error.html @@ -0,0 +1,18 @@ +{{define "error.html"}} + + + + + {{.Title}} + + + + +

{{.Title}}

+

{{.Message}}

+
+

Sign In

+ + + +{{end}} diff --git a/gatekeeper/templates/sign_in.html b/gatekeeper/templates/sign_in.html new file mode 100644 index 000000000..ddd0e92f1 --- /dev/null +++ b/gatekeeper/templates/sign_in.html @@ -0,0 +1,119 @@ +{{define "sign_in.html"}} + + + + Sign In with passbook + + + + + +
+
+ +
+
+
+ + + + +{{end}} diff --git a/passbook/providers/app_gw/api.py b/passbook/providers/app_gw/api.py index 12a61f737..ae386c58a 100644 --- a/passbook/providers/app_gw/api.py +++ b/passbook/providers/app_gw/api.py @@ -1,18 +1,42 @@ """ApplicationGatewayProvider API Views""" +from oauth2_provider.generators import (generate_client_id, + generate_client_secret) +from oidc_provider.models import Client from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet from passbook.providers.app_gw.models import ApplicationGatewayProvider +from passbook.providers.oidc.api import OpenIDProviderSerializer class ApplicationGatewayProviderSerializer(ModelSerializer): """ApplicationGatewayProvider Serializer""" + client = OpenIDProviderSerializer() + + def create(self, validated_data): + instance = super().create(validated_data) + instance.client = Client.objects.create( + client_id=generate_client_id(), + client_secret=generate_client_secret()) + instance.save() + return instance + + def update(self, instance, validated_data): + self.instance.client.name = self.instance.name + self.instance.client.redirect_uris = [ + f"http://{self.instance.host}/oauth2/callback", + f"https://{self.instance.host}/oauth2/callback", + ] + self.instance.client.scope = ['openid', 'email'] + self.instance.client.save() + return super().update(instance, validated_data) + class Meta: model = ApplicationGatewayProvider - fields = ['pk', 'server_name', 'upstream', 'enabled', 'authentication_header', - 'default_content_type', 'upstream_ssl_verification', 'property_mappings'] + fields = ['pk', 'name', 'host', 'client'] + read_only_fields = ['client'] class ApplicationGatewayProviderViewSet(ModelViewSet): """ApplicationGatewayProvider Viewset""" diff --git a/passbook/providers/app_gw/forms.py b/passbook/providers/app_gw/forms.py index 23eadd2d6..96abe6709 100644 --- a/passbook/providers/app_gw/forms.py +++ b/passbook/providers/app_gw/forms.py @@ -1,67 +1,37 @@ """passbook Application Security Gateway Forms""" -from urllib.parse import urlparse - from django import forms -from django.contrib.admin.widgets import FilteredSelectMultiple -from django.forms import ValidationError -from django.utils.translation import gettext as _ +from oauth2_provider.generators import (generate_client_id, + generate_client_secret) +from oidc_provider.models import Client -from passbook.lib.fields import DynamicArrayField -from passbook.providers.app_gw.models import (ApplicationGatewayProvider, - RewriteRule) +from passbook.providers.app_gw.models import ApplicationGatewayProvider class ApplicationGatewayProviderForm(forms.ModelForm): """Security Gateway Provider form""" - def clean_server_name(self): - """Check if server_name is in DB already, since - Postgres ArrayField doesn't suppport keys.""" - current = self.cleaned_data.get('server_name') - if ApplicationGatewayProvider.objects \ - .filter(server_name__overlap=current) \ - .exclude(pk=self.instance.pk).exists(): - raise ValidationError(_("Server Name already in use.")) - return current - - def clean_upstream(self): - """Check that upstream begins with http(s)""" - for upstream in self.cleaned_data.get('upstream'): - _parsed_url = urlparse(upstream) - - if _parsed_url.scheme not in ('http', 'https'): - raise ValidationError(_("URL Scheme must be either http or https")) - return self.cleaned_data.get('upstream') + def save(self, *args, **kwargs): + if not self.instance.pk: + # New instance, so we create a new OIDC client with random keys + self.instance.client = Client.objects.create( + client_id=generate_client_id(), + client_secret=generate_client_secret()) + self.instance.client.name = self.instance.name + self.instance.client.redirect_uris = [ + f"http://{self.instance.host}/oauth2/callback", + f"https://{self.instance.host}/oauth2/callback", + ] + self.instance.client.scope = ['openid', 'email'] + self.instance.client.save() + return super().save(*args, **kwargs) class Meta: model = ApplicationGatewayProvider - fields = ['server_name', 'upstream', 'enabled', 'authentication_header', - 'default_content_type', 'upstream_ssl_verification', 'property_mappings'] - widgets = { - 'authentication_header': forms.TextInput(), - 'default_content_type': forms.TextInput(), - 'property_mappings': FilteredSelectMultiple(_('Property Mappings'), False) - } - field_classes = { - 'server_name': DynamicArrayField, - 'upstream': DynamicArrayField - } - labels = { - 'upstream_ssl_verification': _('Verify upstream SSL Certificates?'), - 'property_mappings': _('Rewrite Rules') - } - -class RewriteRuleForm(forms.ModelForm): - """Rewrite Rule Form""" - - class Meta: - - model = RewriteRule - fields = ['name', 'match', 'halt', 'replacement', 'redirect', 'conditions'] + fields = [ + 'name', 'host' + ] widgets = { 'name': forms.TextInput(), - 'match': forms.TextInput(attrs={'data-is-monospace': True}), - 'replacement': forms.TextInput(attrs={'data-is-monospace': True}), - 'conditions': FilteredSelectMultiple(_('Conditions'), False) + 'host': forms.TextInput(), } diff --git a/passbook/providers/app_gw/migrations/0002_auto_20191111_1703.py b/passbook/providers/app_gw/migrations/0002_auto_20191111_1703.py new file mode 100644 index 000000000..27b67bff9 --- /dev/null +++ b/passbook/providers/app_gw/migrations/0002_auto_20191111_1703.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.7 on 2019-11-11 17:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('passbook_core', '0005_merge_20191025_2022'), + ('passbook_providers_app_gw', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='rewriterule', + name='conditions', + ), + migrations.RemoveField( + model_name='rewriterule', + name='propertymapping_ptr', + ), + migrations.DeleteModel( + name='ApplicationGatewayProvider', + ), + migrations.DeleteModel( + name='RewriteRule', + ), + ] diff --git a/passbook/providers/app_gw/migrations/0003_applicationgatewayprovider.py b/passbook/providers/app_gw/migrations/0003_applicationgatewayprovider.py new file mode 100644 index 000000000..c79eda864 --- /dev/null +++ b/passbook/providers/app_gw/migrations/0003_applicationgatewayprovider.py @@ -0,0 +1,32 @@ +# Generated by Django 2.2.7 on 2019-11-11 17:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('passbook_core', '0005_merge_20191025_2022'), + ('oidc_provider', '0026_client_multiple_response_types'), + ('passbook_providers_app_gw', '0002_auto_20191111_1703'), + ] + + operations = [ + migrations.CreateModel( + name='ApplicationGatewayProvider', + fields=[ + ('provider_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Provider')), + ('name', models.TextField()), + ('host', models.TextField()), + ('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client')), + ], + options={ + 'verbose_name': 'Application Gateway Provider', + 'verbose_name_plural': 'Application Gateway Providers', + }, + bases=('passbook_core.provider',), + ), + ] diff --git a/passbook/providers/app_gw/models.py b/passbook/providers/app_gw/models.py index febb7c9e6..bcd6fb990 100644 --- a/passbook/providers/app_gw/models.py +++ b/passbook/providers/app_gw/models.py @@ -1,74 +1,39 @@ """passbook app_gw models""" -import re +import string +from random import SystemRandom -from django.contrib.postgres.fields import ArrayField from django.db import models from django.utils.translation import gettext as _ +from oidc_provider.models import Client -from passbook.core.models import Policy, PropertyMapping, Provider +from passbook import __version__ +from passbook.core.models import Provider class ApplicationGatewayProvider(Provider): - """Virtual server which proxies requests to any hostname in server_name to upstream""" + """This provider uses oauth2_proxy with the OIDC Provider.""" - server_name = ArrayField(models.TextField()) - upstream = ArrayField(models.TextField()) - enabled = models.BooleanField(default=True) + name = models.TextField() + host = models.TextField() - authentication_header = models.TextField(default='X-Remote-User', blank=True) - default_content_type = models.TextField(default='application/octet-stream') - upstream_ssl_verification = models.BooleanField(default=True) + client = models.ForeignKey(Client, on_delete=models.CASCADE) form = 'passbook.providers.app_gw.forms.ApplicationGatewayProviderForm' - @property - def name(self): - """since this model has no name property, return a joined list of server_names as name""" - return ', '.join(self.server_name) + def html_setup_urls(self, request): + """return template and context modal with URLs for authorize, token, openid-config, etc""" + cookie_secret = ''.join(SystemRandom().choice( + string.ascii_uppercase + string.digits) for _ in range(50)) + return "app_gw/setup_modal.html", { + 'provider': self, + 'cookie_secret': cookie_secret, + 'version': __version__ + } def __str__(self): - return "Application Gateway %s" % ', '.join(self.server_name) + return f"Application Gateway {self.name}" class Meta: verbose_name = _('Application Gateway Provider') verbose_name_plural = _('Application Gateway Providers') - - -class RewriteRule(PropertyMapping): - """Rewrite requests matching `match` with `replacement`, if all polcies in `conditions` apply""" - - REDIRECT_INTERNAL = 'internal' - REDIRECT_PERMANENT = 301 - REDIRECT_FOUND = 302 - - REDIRECTS = ( - (REDIRECT_INTERNAL, _('Internal')), - (REDIRECT_PERMANENT, _('Moved Permanently')), - (REDIRECT_FOUND, _('Found')), - ) - - match = models.TextField() - halt = models.BooleanField(default=False) - conditions = models.ManyToManyField(Policy, blank=True) - replacement = models.TextField() # python formatted strings, use {match.1} - redirect = models.CharField(max_length=50, choices=REDIRECTS) - - form = 'passbook.providers.app_gw.forms.RewriteRuleForm' - - _matcher = None - - @property - def compiled_matcher(self): - """Cache the compiled regex in memory""" - if not self._matcher: - self._matcher = re.compile(self.match) - return self._matcher - - def __str__(self): - return "Rewrite Rule %s" % self.name - - class Meta: - - verbose_name = _('Rewrite Rule') - verbose_name_plural = _('Rewrite Rules') diff --git a/passbook/providers/app_gw/templates/app_gw/k8s-manifest.yaml b/passbook/providers/app_gw/templates/app_gw/k8s-manifest.yaml new file mode 100644 index 000000000..c1497835d --- /dev/null +++ b/passbook/providers/app_gw/templates/app_gw/k8s-manifest.yaml @@ -0,0 +1,64 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + k8s-app: passbook-gatekeeper + name: passbook-gatekeeper + namespace: kube-system +spec: + replicas: 1 + selector: + matchLabels: + k8s-app: passbook-gatekeeper + template: + metadata: + labels: + k8s-app: passbook-gatekeeper + spec: + containers: + - args: + - --upstream=file:///dev/null + env: + - name: OAUTH2_PROXY_CLIENT_ID + value: {{ provider.client.client_id }} + - name: OAUTH2_PROXY_CLIENT_SECRET + value: {{ provider.client.client_secret }} + - name: OAUTH2_PROXY_COOKIE_SECRET + value: {{ cookie_secret }} + image: docker.beryju.org/passbook/gatekeeper:{{ version }} + imagePullPolicy: Always + name: passbook-gatekeeper + ports: + - containerPort: 4180 + protocol: TCP +--- +apiVersion: v1 +kind: Service +metadata: + labels: + k8s-app: passbook-gatekeeper + name: passbook-gatekeeper + namespace: kube-system +spec: + ports: + - name: http + port: 4180 + protocol: TCP + targetPort: 4180 + selector: + k8s-app: passbook-gatekeeper +--- +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: passbook-gatekeeper + namespace: kube-system +spec: + rules: + - host: {{ provider.host }} + http: + paths: + - backend: + serviceName: passbook-gatekeeper + servicePort: 4180 + path: /oauth2 diff --git a/passbook/providers/app_gw/templates/app_gw/setup_modal.html b/passbook/providers/app_gw/templates/app_gw/setup_modal.html new file mode 100644 index 000000000..3f2f26910 --- /dev/null +++ b/passbook/providers/app_gw/templates/app_gw/setup_modal.html @@ -0,0 +1,90 @@ +{% load i18n %} +{% load static %} + + + + + + + + + + + + + diff --git a/passbook/providers/app_gw/urls.py b/passbook/providers/app_gw/urls.py index 62cbc1ed9..8546b838b 100644 --- a/passbook/providers/app_gw/urls.py +++ b/passbook/providers/app_gw/urls.py @@ -1,8 +1,8 @@ """passbook app_gw urls""" from django.urls import path -from passbook.providers.app_gw.views import NginxCheckView +from passbook.providers.app_gw.views import K8sManifestView urlpatterns = [ - path('nginx/', NginxCheckView.as_view()) + path('/k8s-manifest/', K8sManifestView.as_view(), name='k8s-manifest'), ] diff --git a/passbook/providers/app_gw/views.py b/passbook/providers/app_gw/views.py index 56148b940..6cfee4236 100644 --- a/passbook/providers/app_gw/views.py +++ b/passbook/providers/app_gw/views.py @@ -1,49 +1,33 @@ """passbook app_gw views""" -from urllib.parse import urlparse +import string +from random import SystemRandom -from django.conf import settings -from django.core.cache import cache +from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, render from django.views import View from structlog import get_logger -from passbook.core.views.access import AccessMixin +from passbook import __version__ from passbook.providers.app_gw.models import ApplicationGatewayProvider ORIGINAL_URL = 'HTTP_X_ORIGINAL_URL' LOGGER = get_logger() -def cache_key(session_cookie: str, request: HttpRequest) -> str: - """Cache Key for request fingerprinting""" - fprint = '_'.join([ - session_cookie, - request.META.get('HTTP_HOST'), - request.META.get('PATH_INFO'), - ]) - return f"app_gw_{fprint}" +def get_cookie_secret(): + """Generate random 50-character string for cookie-secret""" + return ''.join(SystemRandom().choice( + string.ascii_uppercase + string.digits) for _ in range(50)) -class NginxCheckView(AccessMixin, View): - """View used by nginx's auth_request module""" - def dispatch(self, request: HttpRequest) -> HttpResponse: - session_cookie = request.COOKIES.get(settings.SESSION_COOKIE_NAME, '') - _cache_key = cache_key(session_cookie, request) - if cache.get(_cache_key): - return HttpResponse(status=202) - parsed_url = urlparse(request.META.get(ORIGINAL_URL)) - # request.session[AuthenticationView.SESSION_ALLOW_ABSOLUTE_NEXT] = True - # request.session[AuthenticationView.SESSION_FORCE_COOKIE_HOSTNAME] = parsed_url.hostname - if not request.user.is_authenticated: - return HttpResponse(status=401) - matching = ApplicationGatewayProvider.objects.filter( - server_name__contains=[parsed_url.hostname]) - if not matching.exists(): - LOGGER.debug("Couldn't find matching application", host=parsed_url.hostname) - return HttpResponse(status=403) - application = self.provider_to_application(matching.first()) - has_access, _ = self.user_has_access(application, request.user) - if has_access: - cache.set(_cache_key, True) - return HttpResponse(status=202) - LOGGER.debug("User not passing", user=request.user) - return HttpResponse(status=401) +class K8sManifestView(LoginRequiredMixin, View): + """Generate K8s Deployment and SVC for gatekeeper""" + + def get(self, request: HttpRequest, provider: int) -> HttpResponse: + """Render deployment template""" + provider = get_object_or_404(ApplicationGatewayProvider, pk=provider) + return render(request, 'app_gw/k8s-manifest.yaml', { + 'provider': provider, + 'cookie_secret': get_cookie_secret(), + 'version': __version__ + }, content_type='text/yaml')