Commit 7d01d42b authored by BAIRE Anthony's avatar BAIRE Anthony
Browse files

Add webapp deploy tokens

- add the Token model to store the authentication tokens
- add the WebappTokenCreate & WebappTokenDelete views to manage
  the tokens
- enable the docker panel in the WebappSandboxPanel
- rename the jwt token class 'Token' as 'JwtToken'
  (to avoid any confusion)
- enable access to the registry (only for pushing for the moment)

close #44
close allgo.inria.fr#5
mitigates #227
parent c59e967e
Pipeline #134128 failed with stages
in 0 seconds
......@@ -13,6 +13,7 @@ from .models import (
Job,
JobQueue,
Runner,
Token,
Webapp,
WebappParameter,
)
......@@ -211,3 +212,21 @@ class WebappImportForm(forms.Form):
webapp_id = forms.IntegerField(label="Webapp ID", required=False)
docker_name = forms.CharField(label="Short name", required=False,
validators=[docker_name_validator])
class WebappTokenForm(forms.ModelForm):
name = forms.CharField(help_text="An arbitrary name for this token")
lifetime = forms.ChoiceField(initial="7", required=False,
choices=[
("1", "1 day"),
("7", "1 week"),
("30", "1 month"),
("91", "3 months"),
("182", "6 months"),
("365", "1 year"),
("1095","3 years"),
("0", "unlimited"),
])
class Meta:
model = Token
fields = ("name", "expires_at")
from __future__ import unicode_literals
import datetime
import os
from django.conf import settings
from django.contrib import auth
from django.contrib.auth.models import User, AnonymousUser
from django.core.validators import MinValueValidator, RegexValidator
from django.core.validators import MinValueValidator, RegexValidator, ValidationError
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
......@@ -263,7 +264,7 @@ class Webapp(TimeStampModel):
def is_pullable_by(self, actor, *, client_ip=None):
"""Return True if the given actor is allowed to pull an image of this webapp
`actor` may be a User, AllgoUser, Runner or None
`actor` may be a User, AllgoUser, Runner, Token or None
`client_ip` is client IP address (used for limiting admin/open_bar
access to the adresses listed in ALLGO_ALLOWED_IP_ADMIN)
......@@ -272,6 +273,9 @@ class Webapp(TimeStampModel):
and is_allowed_ip_admin(client_ip)):
return True
if isinstance(actor, Token):
# deploy tokens are just for pushing (for the moment)
return False
user = self._resolve_user(actor)
if user == self.user:
return True
......@@ -282,8 +286,10 @@ class Webapp(TimeStampModel):
def is_pushable_by(self, actor):
"""Return True if the given actor is allowed to push an image of this webapp
`actor` may be a User, AllgoUser, Runner or None
`actor` may be a User, AllgoUser, Runner, Token or None
"""
if isinstance(actor, Token):
return actor.webapp == self and actor.user is None
return self._resolve_user(actor) == self.user
......@@ -648,6 +654,86 @@ class UserAgreement(BaseModel):
db_table = 'dj_user_agreement'
class Token(BaseModel):
"""Authentication token model
Tokens are 64 bytes long and are made of:
- a 12-byte unique id
- a 52-byte secret
The secrets are stored as a digest (using a password hasher), therefore the plaintext is
displayed to the user at creation time and cannot be recovered aftefwards.
They may have an expiration date (if null, the token never expires).
For the moment, the tokens ae only for pushing a new WebappVersion with the docker registry API.
"""
id = models.CharField(max_length=12, primary_key=True,
validators=[RegexValidator(r'\A[a-zA-Z0-9]{12}\Z')])
secret = models.CharField(max_length=128)
name = models.CharField(max_length=128)
user = models.ForeignKey(User, null=True, blank=True, on_delete=models.CASCADE)
webapp = models.ForeignKey(Webapp, null=True, blank=True, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateField(null=True, blank=True)
_hasher = auth.hashers.PBKDF2PasswordHasher()
@classmethod
def generate(cls, **kw):
"""Generate a new token
The 'id' and 'secret' fields are automatically generated.
The extra '**kw' arguments provide the values for the other fields.
Return a tuple with:
- the newly created Token object
- a string containing the plain unencrypted token
"""
assert "id" not in kw
assert "secret" not in kw
secret = get_random_string(52)
kw["secret"] = cls._hasher.encode(secret, cls._hasher.salt())
# Collisions on the id are very unlikely. If this happens then we retry at most 10 times.
for _ in range(10):
try:
token = Token(id=get_random_string(12), **kw)
token.save()
return token, token.id+secret
except ValidationError as e:
if "id" not in e.error_dict:
raise
raise ValueError("too many collisions")
@classmethod
def authenticate(cls, raw_token: str):
"""Authenticate with a token
This function looks up the token in the db, verifies it and returns the relevant Token
object.
It returns None if the token is not valid (if not found, if expired or if the secret does
not match).
"""
token = Token.objects.filter(id=raw_token[:12]).first()
if ( (token is not None)
and ((token.expires_at is None) or (token.expires_at > datetime.date.today()))
and cls._hasher.verify(raw_token[12:], token.secret)):
return token
return None
class Meta:
db_table = 'dj_tokens'
verbose_name = 'allgo token'
verbose_name_plural = 'allgo tokens'
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
......
......@@ -56,6 +56,10 @@ urlpatterns = [
url(r'^apps/_import/$', views.WebappImport.as_view(), name='webapp_import'),
url(r'^apps/(?P<docker_name>[\w-]+)/import$', views.WebappVersionImport.as_view(),
name="webapp_version_import"),
url(r'^apps/(?P<docker_name>[\w-]+)/tokens$', views.WebappTokenCreate.as_view(),
name="webapp_token_create"),
url(r'^apps/(?P<docker_name>[\w-]+)/tokens/(?P<token>\w+)/delete$', views.WebappTokenDelete.as_view(),
name="webapp_token_delete"),
url(r'^apps/(?P<docker_name>[\w-]+)/update$', views.WebappUpdate.as_view(),
name="webapp_update"),
url(r'^apps/(?P<docker_name>[\w-]+)/sandbox$', views.WebappSandboxPanel.as_view(),
......
......@@ -20,6 +20,7 @@ Attributes:
# Python standard libraries
import datetime
import itertools
import logging
import os
......@@ -43,6 +44,7 @@ from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import ValidationError
from django.db import transaction
from django.db.models import Q
from django.http import HttpResponse, JsonResponse, HttpResponseRedirect, FileResponse, Http404
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
......@@ -73,6 +75,7 @@ from .forms import (
RunnerForm,
WebappForm,
WebappImportForm,
WebappTokenForm,
)
from .helpers import (
get_base_url,
......@@ -94,6 +97,7 @@ from .models import (
Webapp,
WebappParameter,
WebappVersion,
Token,
Tos,
UserAgreement,
)
......@@ -629,7 +633,7 @@ class WebappSandboxPanel(UserAccessMixin, TemplateView):
"""
context = super().get_context_data(**kwargs)
context['webapp'] = self.get_object()
context['webapp'] = webapp = self.get_object()
context["ssh_command"] = "ssh%s %s@%s" % (
(" -p %s" % config.env.ALLGO_SSH_PORT
......@@ -647,6 +651,12 @@ class WebappSandboxPanel(UserAccessMixin, TemplateView):
webapp=context["webapp"], state=state))
context['versions'] = natsort.versorted(versions.values(), key=lambda v: v.number)
context['versions'].reverse()
# docker parameters
context['repository'] = "%s/%s" % (
get_base_url(self.request).split("//", 1)[1], webapp.docker_name)
return context
def post(self, request, *, docker_name): # pylint: disable=unused-variable
......@@ -755,6 +765,66 @@ class WebappSandboxPanel(UserAccessMixin, TemplateView):
# page.
return HttpResponseRedirect(request.path_info)
class WebappTokenCreate(UserAccessMixin, SuccessMessageMixin, CreateView):
"""Create a new webapp deploy token
This view lists the existing valid tokens for a given webapp and provides a form to create a new
token.
"""
model = Token
form_class = WebappTokenForm
template_name = "webapp_token_create.html"
def get_webapp(self):
"""Returns the webapp object according to its docker name or a 404 error"""
return get_object_or_404(Webapp, docker_name=self.kwargs["docker_name"],
user_id=self.request.user.id)
def get_context_data(self, **kw):
ctx=super().get_context_data(**kw)
ctx["webapp"] = webapp = self.get_webapp()
ctx["new_token"] = self.request.session.pop("new_webapp_token", None)
ctx["token_list"] = (Token.objects.filter(webapp=webapp, user=None)
.filter(Q(expires_at=None)|Q(expires_at__gt=datetime.date.today()))
.order_by("-created_at"))
ctx['repository'] = "%s/%s" % (
get_base_url(self.request).split("//", 1)[1], webapp.docker_name)
return ctx
def form_valid(self, form):
webapp = self.get_webapp()
lifetime = int(form.cleaned_data["lifetime"])
expires_at = (datetime.date.today() + datetime.timedelta(lifetime)) if lifetime else None
token, raw = Token.generate(webapp=webapp, name=form.cleaned_data["name"],
expires_at=expires_at)
messages.success(self.request, "Token created")
self.request.session["new_webapp_token"] = raw
return HttpResponseRedirect("")
class WebappTokenDelete(UserAccessMixin, View):
"""Delete a webapp deploy token
This view attemps to delete the given token and redirects back to the token creation view.
"""
success_message = "Token deleted"
failure_message = "Token not found"
def get_webapp(self):
"""Returns the webapp object according to its docker name or a 404 error"""
return get_object_or_404(Webapp, docker_name=self.kwargs["docker_name"],
user_id=self.request.user.id)
def post(self, request, docker_name, token):
if Token.objects.filter(id=token, webapp=self.get_webapp(), user=None).delete()[0]:
messages.success(request, "Token deleted")
else:
messages.error(request, "Token not found")
return HttpResponseRedirect(reverse("main:webapp_token_create", args=(docker_name,)))
# TAGS
# -----------------------------------------------------------------------------
......
......@@ -17,9 +17,7 @@
<div class="tab-content">
<ul class="nav nav-tabs">
<li class="nav-item"><a class="nav-link active" data-toggle="tab" href="#ssh"><i class="fas fa-terminal"></i> SSH</a></li>
{% comment Disabled until #227 is implemented %}
<li class="nav-item"><a class="nav-link" data-toggle="tab" href="#docker"><i class="fab fa-docker"></i> Docker</a></li>
{% endcomment %}
</ul>
<div class="tab-pane active" id="ssh">
......@@ -30,7 +28,7 @@
<ul>
<li>start a sandbox, connect to it via ssh, then install the
application manually</li>
<li><del>create a docker image and push it to allgo (see the Docker pane)</del> (not yet available)</li>
<li>create a docker image and push it to allgo (see the Docker pane)</li>
</ul>
</p>
......@@ -239,12 +237,26 @@
<p><i class="fas fa-question-circle"></i> Need help? <a href="#" title="A||go user documentation">report to the documentation</a>.</p>
</div>
{########### DOCKER ###########}
<div class="tab-pane" id="docker">
<p class="mt-3">You can as well directly push your docker image.</p>
<h5 class="mt-3">Push a docker image</h5>
Allgo supports the <a
href="https://docs.docker.com/registry/">Docker Registry</a> API.
You may package a new version of your application into a docker
image and push it to deploy it on Allgo.
<pre class="language-bash"><code class="language-*">docker login --username {{ user.get_username }} {{ request.get_host }}/{{ webapp.docker_name }}
docker build -t {{ webapp.docker_name }} .
docker push {{ request.get_host }}/{{ webapp.docker_name }}:&lt;version&gt;</code></pre>
<p class="mt-3">You will need to generate a secret token
authenticate with the registry.</p>
<a href="{% url "main:webapp_token_create" webapp.docker_name %}" class="btn btn-primary">Manage deploy tokens</a>
<pre class="language-bash mt-5"><code class="language-*"
># Build your image and tag it as '{{repository}}:VERSION'
docker build -t {{ repository }}:latest .
# Login on the allgo repository and push the '{{repository}}:VERSION' image
docker login -u token -p XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX {{ repository }}
docker push {{ repository }}:latest</code></pre>
</div>
</div>
</div>
......
{% extends "base.html" %}
{% load static converters htmlattrs humanize %}
{% block title %}{{ webapp.name | fancy_webapp_name | title }} tokens{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{% url 'main:webapp_list' %}">Applications</a></li>
<li class="breadcrumb-item"><a href="{% url 'main:webapp_detail' webapp.docker_name %}">{{ webapp.name }}</a></li>
<li class="breadcrumb-item active" aria-current="page">Tokens</li>
{% endblock %}
{% block content %}
<div class="container">
<div class="allgo-page">
<h1>Deploy tokens</h1>
</div>
{% if new_token %}
<div class="card mb-5">
<div class="card-body">
<h5 class="card-title">New token</h5>
<div class="form-inline card-text">
<div class="mr-2">Your new token is:</div>
<div class="input-group flex-fill">
<input type="text" class="form-control" value="{{ new_token }}" readonly>
<div class="input-group-append">
<button
class="btn btn-primary js-copy"
data-toggle="tooltip"
data-copy="{{ new_token }}"
data-placement="top"
title="Copy to clipboard" type="button"><i class="fas fa-clipboard"></i><span class="text-hide">Copy to clipboard</span></button>
</div>
</div>
</div>
<p class="card-text text-danger mt-3">Make sure you save it, you won't be able to access it again.</p>
<p class="card-text">You may now login to the docker repository and push an image with the following command:</p>
<pre class="language-bash"><code class="language-*"
>docker login -u token -p {{ new_token }} {{ repository }}
docker push {{ repository }}:latest</code></pre>
</pre>
</div>
</div>
{% endif %}
<form method="post" class="mb-5">
{% csrf_token %}
<div class="form-row">
<div class="col-sm-6 col-lg-4">
<div class="form-group">
{{ form.name.label_tag }}
{% if form.name.errors %}
{{ form.name | add_class:"form-control is-invalid" }}
{% else %}
{{ form.name | add_class:"form-control"}}
{% endif %}
<small class="form-text text-muted">{{ form.name.help_text }}</small>
</div>
</div>
<div class="col-sm-3 col-lg-2">
<div class="form-group">
{{ form.lifetime.label_tag }}
{{ form.lifetime | add_class:"form-control" }}
<small class="form-text text-muted">{{ form.lifetime.help_text }}</small>
</div>
</div>
<div class="col-sm-3 col-lg-2">
<div class="form-group">
<label>&nbsp;</label>
<input class="btn btn-primary form-control" type="submit" value="Create token">
</div>
</div>
</div>
</form>
<table class="table table-striped">
<thead>
<tr>
<th scope="col">Token</th>
<th scope="col">Name</th>
<th scope="col">Created</th>
<th scope="col">Expires</th>
<th scope="col">Action</th>
</thead>
<tbody>
{% for token in token_list %}
<tr>
<th scope="row"><tt>{{token.id}}</tt></th>
<td>{{token.name}}</td>
<td>{{token.created_at.date}}</td>
<td>{{token.expires_at | default:"<i class='text-secondary'>never</i>"}}</td>
<td><form action="{%url 'main:webapp_token_delete' webapp.docker_name token.id %}" method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger"><i class="fas fa-trash-alt"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
{% block messages %}
{{ block.super }}
{% include 'partials/_form_messages.html' %}
{% endblock %}
{% block javascript %}
{{ block.super }}
<script defer src="{% static 'js/tooltip.js' %}"></script>
{% endblock %}
......@@ -50,7 +50,7 @@ def kid_from_crypto_key(private_key_path, key_type):
return key_id_encode(algorithm.digest()[:30])
class Token(object):
class JwtToken(object):
def __init__(self, service, access_type="", access_name="", access_actions=None, subject=''):
if access_actions is None:
access_actions = []
......
......@@ -4,10 +4,10 @@ import logging
import config.env
from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from main.models import User, Runner, Webapp, WebappVersion
from main.models import User, Runner, Token, Webapp, WebappVersion
from main.helpers import is_allowed_ip_admin
from .tokens import Token
from .tokens import JwtToken
log = logging.getLogger('jwt')
......@@ -180,21 +180,23 @@ def jwt_auth(request):
if password == CONTROLLER_TOKEN:
actor = "CONTROLLER"
else:
try:
actor = Runner.objects.get(token=password)
log.info("Token for runner called")
except Runner.DoesNotExist:
actor = Token.authenticate(password)
if actor is None:
return HttpResponse(status=401)
else:
try:
actor = User.objects.get(email=username)
except User.DoesNotExist:
log.warning("Token request but user doest not exist")
return HttpResponse(status=401)
password_valid = actor.check_password(password)
if token_type != 'Basic' or not password_valid:
log.info("Token request but user password mismatch")
return HttpResponse(status=401)
# FIXME: user authentication is disabled for the moment because we do not work with users
# authenticated via allauth
return HttpResponse(status=401)
#
# try:
# actor = User.objects.get(email=username)
# except User.DoesNotExist:
# log.warning("Token request but user doest not exist")
# return HttpResponse(status=401)
# password_valid = actor.check_password(password)
# if token_type != 'Basic' or not password_valid:
# log.info("Token request but user password mismatch")
# return HttpResponse(status=401)
#
# Evaluate the allowed actions
......@@ -204,29 +206,35 @@ def jwt_auth(request):
except ValueError:
return JsonResponse({'error': 'Invalid scope parameter'}, status=400)
allowed_actions = []
allowed_actions = set()
if resource_type == "repository":
if actor == "CONTROLLER":
if is_allowed_ip_admin(get_client_ip(request)):
allowed_actions.extend(("pull", "push"))
allowed_actions.update(("pull", "push"))
else:
try:
webapp = Webapp.objects.get(docker_name = repository)
except Webapp.DoesNotExist:
pass
else:
if "push" in requested_actions and webapp.is_pushable_by(actor):
allowed_actions.add("push")
if "pull" in requested_actions:
# NOTE: the official docker client requests both push & pull rights for
# pushing (and it is unable to push without the pull permission)
# (anyway the nginx config prevents pulling blobs for the moment)
allowed_actions.add("pull")
if "pull" in requested_actions and webapp.is_pullable_by(actor,
client_ip = get_client_ip(request)):
allowed_actions.append("pull")
if "push" in requested_actions and webapp.is_pushable_by(actor):
allowed_actions.append("push")
allowed_actions.add("pull")
#
# Generate the token
#
service = request.GET['service']
log.info("Token authorized for %s on %s actions %s", actor, repository, allowed_actions)
token = Token(service, resource_type, repository, allowed_actions)
token = JwtToken(service, resource_type, repository, list(allowed_actions))
return JsonResponse({'token': token.encode_token()})
......
......@@ -102,22 +102,30 @@ server
}
# Disabled until #227 is implemented
#
# # registry endpoints
# # - forwarded to the registry
# # - except manifest push/pull -> forwarded through the django server (to
# # guarantee that the db is transactionally updated)
# location /v2/
# {
# proxy_pass {ALLGO_REGISTRY_PRIVATE_URL}/v2/;
# proxy_redirect off;
# proxy_buffering off;
#
# location ~ ^/v2/.*/manifests/[^/]*$ {
# proxy_pass http://aio;
# }
# }
# registry endpoints
# - forwarded to the registry
# - except manifest push/pull -> forwarded through the django server (to
# guarantee that the db is transactionally updated)
location /v2/
{
proxy_pass {ALLGO_REGISTRY_PRIVATE_URL}/v2/;
proxy_redirect off;
proxy_buffering off;
location ~ ^/v2/.*/manifests/[^/]*$ {
proxy_pass http://aio;
# for the moment we do not allow deleting images
limit_except GET PUT { deny all; }
}
location ~ ^/v2/.*/blobs/[^/]*$ {
proxy_pass {ALLGO_REGISTRY_PRIVATE_URL};
# for the moment we only allow pushing images
limit_except HEAD { deny all; }
}
}
location /datastore/
......
Markdown is supported
0% or .