Skip to content

Commit

Permalink
Add/authentication (#18)
Browse files Browse the repository at this point in the history
* freezing work to add auth
* start of work to add authentication, need to update conformance tests
* finishing up updating tests to include auth
* forgot to install pyjwt
* finish up work to add authentication and docs

Signed-off-by: vsoch <[email protected]>
  • Loading branch information
vsoch authored Oct 27, 2020
1 parent 5a02d2e commit a596148
Show file tree
Hide file tree
Showing 29 changed files with 913 additions and 142 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
run: |
# temporary fix for https://github.com/actions/setup-go/issues/14
echo "GOPATH=$(dirname $GITHUB_WORKSPACE)" >> $GITHUB_ENV
echo "PATH=$(dirname $GITHUB_WORKSPACE)/bin:${PATH}" >> $GITHUB_PATH
echo "$(dirname $GITHUB_WORKSPACE)/bin" >> $GITHUB_PATH
- name: Compile conformance.test binary
run: |
Expand Down Expand Up @@ -84,5 +84,5 @@ jobs:
python manage.py migrate
python manage.py migrate django_oci
echo ::group::tests.test_conformance
python manage.py test tests.test_conformance
DISABLE_AUTHENTICATION=yes python manage.py test tests.test_conformance
echo ::endgroup::tests.test_conformance
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Changelog

This is a manually generated log to track changes to the repository for each release.
Each section should include general headers such as **Implemented enhancements**
and **Merged pull requests**. All closed issued and bug fixes should be
represented by the pull requests that fixed them.
Critical items to know are:

- renamed commands
- deprecated / removed commands
- changed defaults
- backward incompatible changes
- migration guidance
- changed behaviour

## [master](https://github.com/vsoch/django-oci/tree/master)
- Added authentication (0.0.11)
- Django OCI core release without authentication (0.0.1)
- skeleton release (0.0.0)
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# django-oci

[![PyPI version](https://badge.fury.io/py/django-oci.svg)](https://badge.fury.io/py/django-oci)
![docs/assets/img/django-oci.png](docs/assets/img/django-oci.png)

Open Containers distribution API for Django.
Expand Down
2 changes: 1 addition & 1 deletion django_oci/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__version__ = "0.0.1"
__version__ = "0.0.11"
default_app_config = "django_oci.apps.DjangoOciConfig"
2 changes: 2 additions & 0 deletions django_oci/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ class DjangoOciConfig(AppConfig):

def ready(self):
import django_oci.signals

assert django_oci.signals
260 changes: 260 additions & 0 deletions django_oci/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
"""
Copyright (c) 2020, Vanessa Sochat
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from django.urls import resolve
from django.contrib.auth.models import User

from django_oci import settings
from django_oci.utils import get_server
from django_oci.models import Repository

from rest_framework.authtoken.models import Token
from rest_framework.response import Response

from django.middleware import cache

from datetime import datetime
import uuid
import base64
import re
import time
import jwt


def is_authenticated(
request, repository=None, must_be_owner=True, repository_exists=True
):
"""
Function to check if a request is authenticated, a repository and the request is required.
Returns a boolean to indicate if the user is authenticated, and a response with
the challenge if not. If allow_if_private is True, we only allow access to users
that are owners or contributors, regardless of having a valid token.
Arguments:
==========
request (requests.Request) : the Request object to inspect
repository (str or Repository): the name of a repository or instance
must_be_owner (bool) : if must be owner is true, requires push
reposity_exists (bool) : flag to indicate that the repository exists.
"""
# Derive the view name from the request PATH_INFO
func, two, three = resolve(request.META["PATH_INFO"])
view_name = "%s.%s" % (func.__module__, func.__name__)

# If authentication is disabled, return the original view
if settings.DISABLE_AUTHENTICATION or view_name not in settings.AUTHENTICATED_VIEWS:
return True, None, None

# Ensure repository is valid, only if provided
name = repository
if repository is not None and repository_exists and isinstance(repository, str):
try:
repository = Repository.objects.get(name=repository)
name = repository.name
except Repository.DoesNotExist:
return False, Response(status=404), None

# Case 2: Already has a jwt valid token
is_valid, user = validate_jwt(request, repository, must_be_owner)
if is_valid:
return True, None, user

# Case 3: False and response will return request for auth
user = get_user(request)
if not user:
headers = {"Www-Authenticate": get_challenge(request, name)}
return False, Response(status=401, headers=headers), user

# Denied for any other reason
return False, Response(status=403), user


def generate_jwt(username, scope, realm, repository):
"""Given a username, scope, realm, repository, and service, generate a jwt
token to return to the user with a default expiration of 10 minutes.
Arguments:
==========
username (str) : the user's username to add under "sub"
scope (list) : a list of scopes to require (e.g., ['push, pull'])
realm (str) : the authentication realm, typically <server>/auth
repository (str): the repository name
"""
# The jti expires after TOKEN_EXPIRES_SECONDS
issued_at = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
filecache = cache.caches["django_oci_upload"]
jti = str(uuid.uuid4())
filecache.set(jti, "good", timeout=settings.TOKEN_EXPIRES_SECONDS)
now = int(time.time())
expires_at = now + settings.TOKEN_EXPIRES_SECONDS

# import jwt and generate token
# https://tools.ietf.org/html/rfc7519#section-4.1.5
payload = {
"iss": realm, # auth endpoint
"sub": username,
"exp": expires_at,
"nbf": now,
"iat": now,
"jti": jti,
"access": [{"type": "repository", "name": repository, "actions": scope}],
}
token = jwt.encode(payload, settings.JWT_SERVER_SECRET, algorithm="HS256").decode(
"utf-8"
)
return {
"token": token,
"expires_in": settings.TOKEN_EXPIRES_SECONDS,
"issued_at": issued_at,
}


def validate_jwt(request, repository, must_be_owner):
"""Given a jwt token, decode and validate
Arguments:
==========
request (requests.Request) : the Request object to inspect
repository (models.Repository): the repository instance
must_be_owner (bool) : if True, requires additional push scope
"""
header = request.META.get("HTTP_AUTHORIZATION", "")
if re.search("bearer", header, re.IGNORECASE):
encoded = re.sub("bearer", "", header, flags=re.IGNORECASE).strip()

# Any reason not valid will issue an error here
try:
decoded = jwt.decode(
encoded, settings.JWT_SERVER_SECRET, algorithms=["HS256"]
)
except Exception as exc:
print("jwt could no be decoded, %s" % exc)
return False, None

# Ensure that the jti is still valid
filecache = cache.caches["django_oci_upload"]
if not filecache.get(decoded.get("jti")) == "good":
print("Filecache with jti not found.")
return False, None

# The user must exist
try:
user = User.objects.get(username=decoded.get("sub"))
except User.DoesNotExist:
print("Username %s not found" % decoded.get("sub"))
return False, None

# If a repository exists, the user must be an owner
if (
isinstance(repository, Repository)
and (repository.private or must_be_owner)
and user not in repository.owners.all()
and user not in repository.contributors.all()
):
print("Username %s not in repository owners" % decoded.get("sub"))
return False, None

# If repository is not defined and must be owner, no go
if repository is None and must_be_owner:
print("Repository is None and must be owner")
return False, None

# TODO: any validation needed for access type?

This comment has been minimized.

Copy link
@ad-m

ad-m Oct 28, 2020

In real implementations where the authorization server and resource server are separated validation is required. In your case, you don't see this because get_challenge always asks for all permissions due implementation of is_authenticated and you pass all requested scopes to access.

If you want to follow the Docker implementation (not part of the OCI standard) of decouling authorization and authentication the is_authenticated function should take into account whether the operation being performed is a read or a modification, and then reflect it in the header. Consider this for a semi-open repository (selected can read, anyone can read). In my opinion, all information necessary for authorization should be included in the token.

Once the token server has determined what access the client has to the resources requested in the scope parameter, it will take the intersection of the set of requested actions on each resource and the set of actions that the client has in fact been granted. If the client only has a subset of the requested access it must not be considered an error as it is not the responsibility of the token server to indicate authorization errors as part of this workflow.
Continuing with the example request, the token server will find that the client’s set of granted access to the repository is [pull, push] which when intersected with the requested access [pull, push] yields an equal set. If the granted access set was found only to be [pull] then the intersected set would only be [pull]. If the client has no access to the repository then the intersected set would be empty, [].
https://docs.docker.com/registry/spec/auth/jwt/

If you want to follow the Docker implementation and access (not part of the OCI standard) the is_authenticated function should take into account whether the operation being performed is a read or a modification, and then reflect it in the header. Consider this for a semi-open repository (chosen ones can read, anyone can read). With the Docker approach, all information necessary for authorization is contained in the token and the resource provider does not have direct access to the user database

However, I also see the possibility of a centralized approach where the registry (resource provider) will have direct access to the user database. In that case, it doesn't make sense to enter "scopes" in the header at all. This element of the protocol remained open.

requested_name = decoded.get("access", [{}])[0].get("name")
if isinstance(repository, Repository) and repository.name != requested_name:
print("Repository name is not equal to requested name.")
return False, None

# Do we have the correct permissions?
requested_permission = decoded.get("access", [{}])[0].get("actions")
if must_be_owner and "push" not in requested_permission:
print("Must be owner and push not in requested permissions")
return False, None
return True, user

return False, None


def get_user(request):
"""Given a request, read the Authorization header to get the base64 encoded
username and token (password) which is a basic auth. If we return the user
object, the user is successfully authenticated. Otherwise, return None.
and the calling function should return Forbidden status.
Arguments:
==========
request (requests.Request) : the Request object to inspect
"""
header = request.META.get("HTTP_AUTHORIZATION", "")

if re.search("basic", header, re.IGNORECASE):
encoded = re.sub("basic", "", header, flags=re.IGNORECASE).strip()
decoded = base64.b64decode(encoded).decode("utf-8")
username, token = decoded.split(":", 1)
try:
token = Token.objects.get(key=token)
if token.user.username == username:
return token.user
except:
pass


def get_token(request):
"""The same as validate_token, but return the token object to check the
associated user.
Arguments:
==========
request (requests.Request) : the Request object to inspect
"""
# Coming from HTTP, look for authorization as bearer token
token = request.META.get("HTTP_AUTHORIZATION")

if token:
try:
return Token.objects.get(key=token.replace("BEARER", "").strip())
except Token.DoesNotExist:
pass

# Next attempt - try to get token via user session
elif request.user.is_authenticated and not request.user.is_anonymous:
try:
return Token.objects.get(user=request.user)
except Token.DoesNotExist:
pass


def get_challenge(request, repository, scopes=["pull", "push"]):
"""Given an unauthenticated request, return a challenge in
the Www-Authenticate header
Arguments:
==========
request (requests.Request): the Request object to inspect
repository (str) : the repository name
scopes (list) : list of scopes to return
"""
DOMAIN_NAME = get_server(request)
if not isinstance(scopes, list):
scopes = [scopes]
auth_server = settings.AUTH_SERVER or "%s/auth/token" % DOMAIN_NAME
return 'realm="%s",service="%s",scope="repository:%s:%s"' % (
auth_server,
DOMAIN_NAME,
repository,
",".join(scopes),
)
5 changes: 3 additions & 2 deletions django_oci/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django_oci.settings import MEDIA_ROOT
from io import BytesIO
from django.db import models
from datetime import timezone

import os
import hashlib


class ChunkedUpload(models.Model):
Expand All @@ -42,7 +43,7 @@ def filename(self):

@property
def expires_on(self):
return self.created_on + EXPIRATION_DELTA
return self.created_on + 10000

@property
def expired(self):
Expand Down
14 changes: 8 additions & 6 deletions django_oci/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,15 @@
from django.core.files.storage import FileSystemStorage
from django_oci import settings
from django.urls import reverse
from django.db import models, IntegrityError
from django.db import models
from django.contrib.auth.models import User
from django.middleware import cache
import uuid

import hashlib
import json
import os
import re
import uuid


PRIVACY_CHOICES = (
(False, "Public (The collection will be accessible by anyone)"),
Expand Down Expand Up @@ -138,14 +137,14 @@ class Repository(models.Model):
add_date = models.DateTimeField("date added", auto_now_add=True)
modify_date = models.DateTimeField("date modified", auto_now=True)
owners = models.ManyToManyField(
settings.AUTHENTICATED_USER or User,
User,
blank=True,
default=None,
related_name="container_collection_owners",
related_query_name="owners",
)
contributors = models.ManyToManyField(
settings.AUTHENTICATED_USER or User,
User,
related_name="container_collection_contributors",
related_query_name="contributor",
blank=True,
Expand All @@ -160,6 +159,9 @@ class Repository(models.Model):
verbose_name="Accessibility",
)

def has_view_permission(self, user):
return user in self.owners.all() or user in self.contributors.all()

def get_absolute_url(self):
return reverse("repository_details", args=[str(self.id)])

Expand All @@ -170,7 +172,7 @@ def __unicode__(self):
return self.get_uri()

def get_uri(self):
return "%s:%s" % (self.name, self.image_set.count())
return self.name

def get_label(self):
return "repository"
Expand Down
Loading

0 comments on commit a596148

Please sign in to comment.