-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
Showing
29 changed files
with
913 additions
and
142 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -25,3 +25,5 @@ class DjangoOciConfig(AppConfig): | |
|
||
def ready(self): | ||
import django_oci.signals | ||
|
||
assert django_oci.signals |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
Sorry, something went wrong. |
||
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), | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
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 ofis_authenticated
and you pass all requested scopes toaccess
.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.If you want to follow the Docker implementation and
access
(not part of the OCI standard) theis_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 databaseHowever, 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.