providers/appgw(major): rewrite to use oauth2_proxy

This commit is contained in:
Langhammer, Jens 2019-11-11 18:13:46 +01:00
parent 80ea7c40b7
commit 2997cb83b1
13 changed files with 460 additions and 146 deletions

View File

@ -116,6 +116,18 @@ build-passbook-static:
services: services:
- postgres:latest - postgres:latest
- redis: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: package-helm:
image: debian:stretch-slim 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""" """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.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from passbook.providers.app_gw.models import ApplicationGatewayProvider from passbook.providers.app_gw.models import ApplicationGatewayProvider
from passbook.providers.oidc.api import OpenIDProviderSerializer
class ApplicationGatewayProviderSerializer(ModelSerializer): class ApplicationGatewayProviderSerializer(ModelSerializer):
"""ApplicationGatewayProvider Serializer""" """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: class Meta:
model = ApplicationGatewayProvider model = ApplicationGatewayProvider
fields = ['pk', 'server_name', 'upstream', 'enabled', 'authentication_header', fields = ['pk', 'name', 'host', 'client']
'default_content_type', 'upstream_ssl_verification', 'property_mappings'] read_only_fields = ['client']
class ApplicationGatewayProviderViewSet(ModelViewSet): class ApplicationGatewayProviderViewSet(ModelViewSet):
"""ApplicationGatewayProvider Viewset""" """ApplicationGatewayProvider Viewset"""

View File

@ -1,67 +1,37 @@
"""passbook Application Security Gateway Forms""" """passbook Application Security Gateway Forms"""
from urllib.parse import urlparse
from django import forms from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple from oauth2_provider.generators import (generate_client_id,
from django.forms import ValidationError generate_client_secret)
from django.utils.translation import gettext as _ from oidc_provider.models import Client
from passbook.lib.fields import DynamicArrayField from passbook.providers.app_gw.models import ApplicationGatewayProvider
from passbook.providers.app_gw.models import (ApplicationGatewayProvider,
RewriteRule)
class ApplicationGatewayProviderForm(forms.ModelForm): class ApplicationGatewayProviderForm(forms.ModelForm):
"""Security Gateway Provider form""" """Security Gateway Provider form"""
def clean_server_name(self): def save(self, *args, **kwargs):
"""Check if server_name is in DB already, since if not self.instance.pk:
Postgres ArrayField doesn't suppport keys.""" # New instance, so we create a new OIDC client with random keys
current = self.cleaned_data.get('server_name') self.instance.client = Client.objects.create(
if ApplicationGatewayProvider.objects \ client_id=generate_client_id(),
.filter(server_name__overlap=current) \ client_secret=generate_client_secret())
.exclude(pk=self.instance.pk).exists(): self.instance.client.name = self.instance.name
raise ValidationError(_("Server Name already in use.")) self.instance.client.redirect_uris = [
return current f"http://{self.instance.host}/oauth2/callback",
f"https://{self.instance.host}/oauth2/callback",
def clean_upstream(self): ]
"""Check that upstream begins with http(s)""" self.instance.client.scope = ['openid', 'email']
for upstream in self.cleaned_data.get('upstream'): self.instance.client.save()
_parsed_url = urlparse(upstream) return super().save(*args, **kwargs)
if _parsed_url.scheme not in ('http', 'https'):
raise ValidationError(_("URL Scheme must be either http or https"))
return self.cleaned_data.get('upstream')
class Meta: class Meta:
model = ApplicationGatewayProvider model = ApplicationGatewayProvider
fields = ['server_name', 'upstream', 'enabled', 'authentication_header', fields = [
'default_content_type', 'upstream_ssl_verification', 'property_mappings'] 'name', 'host'
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']
widgets = { widgets = {
'name': forms.TextInput(), 'name': forms.TextInput(),
'match': forms.TextInput(attrs={'data-is-monospace': True}), 'host': forms.TextInput(),
'replacement': forms.TextInput(attrs={'data-is-monospace': True}),
'conditions': FilteredSelectMultiple(_('Conditions'), False)
} }

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""" """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.db import models
from django.utils.translation import gettext as _ 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): 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()) name = models.TextField()
upstream = ArrayField(models.TextField()) host = models.TextField()
enabled = models.BooleanField(default=True)
authentication_header = models.TextField(default='X-Remote-User', blank=True) client = models.ForeignKey(Client, on_delete=models.CASCADE)
default_content_type = models.TextField(default='application/octet-stream')
upstream_ssl_verification = models.BooleanField(default=True)
form = 'passbook.providers.app_gw.forms.ApplicationGatewayProviderForm' form = 'passbook.providers.app_gw.forms.ApplicationGatewayProviderForm'
@property def html_setup_urls(self, request):
def name(self): """return template and context modal with URLs for authorize, token, openid-config, etc"""
"""since this model has no name property, return a joined list of server_names as name""" cookie_secret = ''.join(SystemRandom().choice(
return ', '.join(self.server_name) 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): def __str__(self):
return "Application Gateway %s" % ', '.join(self.server_name) return f"Application Gateway {self.name}"
class Meta: class Meta:
verbose_name = _('Application Gateway Provider') verbose_name = _('Application Gateway Provider')
verbose_name_plural = _('Application Gateway Providers') 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""" """passbook app_gw urls"""
from django.urls import path from django.urls import path
from passbook.providers.app_gw.views import NginxCheckView from passbook.providers.app_gw.views import K8sManifestView
urlpatterns = [ 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""" """passbook app_gw views"""
from urllib.parse import urlparse import string
from random import SystemRandom
from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.cache import cache
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, render
from django.views import View from django.views import View
from structlog import get_logger from structlog import get_logger
from passbook.core.views.access import AccessMixin from passbook import __version__
from passbook.providers.app_gw.models import ApplicationGatewayProvider from passbook.providers.app_gw.models import ApplicationGatewayProvider
ORIGINAL_URL = 'HTTP_X_ORIGINAL_URL' ORIGINAL_URL = 'HTTP_X_ORIGINAL_URL'
LOGGER = get_logger() LOGGER = get_logger()
def cache_key(session_cookie: str, request: HttpRequest) -> str: def get_cookie_secret():
"""Cache Key for request fingerprinting""" """Generate random 50-character string for cookie-secret"""
fprint = '_'.join([ return ''.join(SystemRandom().choice(
session_cookie, string.ascii_uppercase + string.digits) for _ in range(50))
request.META.get('HTTP_HOST'),
request.META.get('PATH_INFO'),
])
return f"app_gw_{fprint}"
class NginxCheckView(AccessMixin, View):
"""View used by nginx's auth_request module"""
def dispatch(self, request: HttpRequest) -> HttpResponse: class K8sManifestView(LoginRequiredMixin, View):
session_cookie = request.COOKIES.get(settings.SESSION_COOKIE_NAME, '') """Generate K8s Deployment and SVC for gatekeeper"""
_cache_key = cache_key(session_cookie, request)
if cache.get(_cache_key): def get(self, request: HttpRequest, provider: int) -> HttpResponse:
return HttpResponse(status=202) """Render deployment template"""
parsed_url = urlparse(request.META.get(ORIGINAL_URL)) provider = get_object_or_404(ApplicationGatewayProvider, pk=provider)
# request.session[AuthenticationView.SESSION_ALLOW_ABSOLUTE_NEXT] = True return render(request, 'app_gw/k8s-manifest.yaml', {
# request.session[AuthenticationView.SESSION_FORCE_COOKIE_HOSTNAME] = parsed_url.hostname 'provider': provider,
if not request.user.is_authenticated: 'cookie_secret': get_cookie_secret(),
return HttpResponse(status=401) 'version': __version__
matching = ApplicationGatewayProvider.objects.filter( }, content_type='text/yaml')
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)