Merge branch 'agw'

This commit is contained in:
Langhammer, Jens 2019-11-11 18:14:03 +01:00
commit 089b48aad1
13 changed files with 460 additions and 146 deletions

View file

@ -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

8
gatekeeper/Dockerfile Normal file
View file

@ -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

View file

@ -0,0 +1,18 @@
{{define "error.html"}}
<!DOCTYPE html>
<html lang="en" charset="utf-8">
<head>
<title>{{.Title}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
</head>
<body>
<h2>{{.Title}}</h2>
<p>{{.Message}}</p>
<hr>
<p><a href="{{.ProxyPrefix}}/sign_in">Sign In</a></p>
</body>
</html>
{{end}}

View file

@ -0,0 +1,119 @@
{{define "sign_in.html"}}
<!DOCTYPE html>
<html lang="en" charset="utf-8">
<head>
<title>Sign In with passbook</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style>
body {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.42857143;
color: #333;
background: #f0f0f0;
}
.signin {
display: block;
margin: 20px auto;
max-width: 400px;
background: #fff;
border: 1px solid #ccc;
border-radius: 10px;
padding: 20px;
}
.center {
text-align: center;
}
.btn {
color: #fff;
background-color: #428bca;
border: 1px solid #357ebd;
-webkit-border-radius: 4;
-moz-border-radius: 4;
border-radius: 4px;
font-size: 14px;
padding: 6px 12px;
text-decoration: none;
cursor: pointer;
}
.btn:hover {
background-color: #3071a9;
border-color: #285e8e;
text-decoration: none;
}
label {
display: inline-block;
max-width: 100%;
margin-bottom: 5px;
font-weight: 700;
}
input {
display: block;
width: 100%;
height: 34px;
padding: 6px 12px;
font-size: 14px;
line-height: 1.42857143;
color: #555;
background-color: #fff;
background-image: none;
border: 1px solid #ccc;
border-radius: 4px;
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);
-webkit-transition: border-color ease-in-out .15s, -webkit-box-shadow ease-in-out .15s;
-o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
margin: 0;
box-sizing: border-box;
}
footer {
display: block;
font-size: 10px;
color: #aaa;
text-align: center;
margin-bottom: 10px;
}
footer a {
display: inline-block;
height: 25px;
line-height: 25px;
color: #aaa;
text-decoration: underline;
}
footer a:hover {
color: #aaa;
}
</style>
</head>
<body>
<div class="signin center">
<form method="GET" action="{{.ProxyPrefix}}/start">
<input type="hidden" name="rd" value="{{.Redirect}}">
<button type="submit" class="btn">Sign in with passbook</button><br />
</form>
</div>
<script>
if (window.location.hash) {
(function () {
var inputs = document.getElementsByName('rd');
for (var i = 0; i < inputs.length; i++) {
inputs[i].value += window.location.hash;
}
})();
}
</script>
</body>
</html>
{{end}}

View file

@ -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"""

View file

@ -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(),
}

View file

@ -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',
),
]

View file

@ -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',),
),
]

View file

@ -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')

View file

@ -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

View file

@ -0,0 +1,90 @@
{% load i18n %}
{% load static %}
<script src="{% static 'codemirror/lib/codemirror.js' %}"></script>
<script src="{% static 'codemirror/addon/display/autorefresh.js' %}"></script>
<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>
<div class="dropdown" style="display: inline-block;">
<button class="btn btn-default btn-sm dropdown-toggle" type="button" id="setupDropdown-{{ provider.pk }}" data-toggle="dropdown">
{% trans 'Setup with...' %}
<span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu" aria-labelledby="setupDropdown-{{ provider.pk }}">
<li role="presentation"><a role="menuitem" data-toggle="modal" data-target="#docker-compose-{{ provider.pk }}">{% trans 'docker-compose' %}</a></li>
<li role="presentation"><a role="menuitem" data-toggle="modal" data-target="#k8s-{{ provider.pk }}">{% trans 'Kubernetes' %}</a></li>
</ul>
</div>
<div class="modal fade" id="docker-compose-{{ provider.pk }}" tabindex="-1" role="dialog" aria-labelledby="{{ provider.pk }}Label" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true" aria-label="Close">
<span class="pficon pficon-close"></span>
</button>
<h4 class="modal-title" id="{{ provider.pk }}Label">{% trans 'Setup with docker-compose' %}</h4>
</div>
<div class="modal-body">
{% trans 'Add the following snippet to your docker-compose file.' %}
<textarea class="codemirror">version: "3.5"
services:
passbook_gatekeeper:
container_name: gatekeeper
image: docker.beryju.org/passbook/gatekeeper:{{ version }}
ports:
- 4180:4180
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_COOKIE_SECRET: {{ cookie_secret }}
OAUTH2_PROXY_UPSTREAM: http://{{ provider.host }}</textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-dismiss="modal">{% trans 'Close' %}</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="k8s-{{ provider.pk }}" tabindex="-1" role="dialog" aria-labelledby="{{ provider.pk }}Label" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true" aria-label="Close">
<span class="pficon pficon-close"></span>
</button>
<h4 class="modal-title" id="{{ provider.pk }}Label">{% trans 'Setup with Kubernetes' %}</h4>
</div>
<div class="modal-body">
<p>{% trans 'Download the manifest to create the Gatekeeper deployment and service:' %}</p>
<a href="{% url 'passbook_providers_app_gw:k8s-manifest' provider=provider.pk %}">{% trans 'Here' %}</a>
<p>{% trans 'Afterwards, add the following annotations to the Ingress you want to secure:' %}</p>
<textarea class="codemirror">
nginx.ingress.kubernetes.io/auth-url: "https://{{ provider.host }}/oauth2/auth"
nginx.ingress.kubernetes.io/auth-signin: "https://{{ provider.host }}/oauth2/start?rd=$escaped_request_uri"
</textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-dismiss="modal">{% trans 'Close' %}</button>
</div>
</div>
</div>
</div>
<script>
let attributes = document.getElementsByClassName('codemirror');
for (let attrib of attributes) {
let myCodeMirror = CodeMirror.fromTextArea(attrib, {
mode: 'yaml',
theme: 'monokai',
lineNumbers: false,
readOnly: true,
autoRefresh: true,
});
}
</script>

View file

@ -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('<int:provider>/k8s-manifest/', K8sManifestView.as_view(), name='k8s-manifest'),
]

View file

@ -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')