-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Proof of Concept: Macaroons #2
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
# 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 warehouse.macaroons.interfaces import IMacaroonService | ||
from warehouse.macaroons.services import database_macaroon_factory | ||
|
||
|
||
def includeme(config): | ||
config.register_service_factory(database_macaroon_factory, IMacaroonService) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,120 @@ | ||
# 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 pyramid.authentication import CallbackAuthenticationPolicy | ||
from pyramid.interfaces import IAuthenticationPolicy, IAuthorizationPolicy | ||
from pyramid.security import Denied | ||
from pyramid.threadlocal import get_current_request | ||
from zope.interface import implementer | ||
|
||
from warehouse.cache.http import add_vary_callback | ||
from warehouse.macaroons.interfaces import IMacaroonService | ||
from warehouse.macaroons.services import InvalidMacaroon | ||
|
||
|
||
def extract_http_macaroon(request): | ||
""" | ||
A helper function for the extraction of HTTP Macaroon from a given request. | ||
Returns either a ``None`` if no macaroon could be found, or the byte string | ||
that represents our Macaroon. | ||
""" | ||
authorization = request.headers.get("Authorization") | ||
if not authorization: | ||
return None | ||
|
||
try: | ||
auth_method, auth = authorization.split(" ", 1) | ||
except ValueError: | ||
return None | ||
|
||
if auth_method.lower() != "macaroon": | ||
return None | ||
|
||
return auth | ||
|
||
|
||
@implementer(IAuthenticationPolicy) | ||
class MacaroonAuthenticationPolicy(CallbackAuthenticationPolicy): | ||
def __init__(self, callback=None): | ||
self.callback = callback | ||
|
||
def unauthenticated_userid(self, request): | ||
# If we're calling into this API on a request, then we want to register | ||
# a callback which will ensure that the response varies based on the | ||
# Authorization header. | ||
request.add_response_callback(add_vary_callback("Authorization")) | ||
|
||
# We need to extract our Macaroon from the request. | ||
macaroon = extract_http_macaroon(request) | ||
|
||
# Check to see if our Macaroon exists in the database, and if so | ||
# fetch the user that is associated with it. | ||
macaroon_service = request.find_service(IMacaroonService, context=None) | ||
return macaroon_service.find_userid(macaroon) | ||
|
||
def remember(self, request, userid, **kw): | ||
# This is a NO-OP because our Macaroon header policy doesn't allow | ||
# the ability for authentication to "remember" the user id. This | ||
# assumes it has been configured in clients somewhere out of band. | ||
return [] | ||
|
||
def forget(self, request): | ||
# This is a NO-OP because our Macaroon header policy doesn't allow | ||
# the ability for authentication to "forget" the user id. This | ||
# assumes it has been configured in clients somewhere out of band. | ||
return [] | ||
|
||
|
||
@implementer(IAuthorizationPolicy) | ||
class MacaroonAuthorizationPolicy: | ||
def __init__(self, policy): | ||
self.policy = policy | ||
|
||
def permits(self, context, principals, permission): | ||
# The Pyramid API doesn't let us access the request here, so we have to pull it | ||
# out of the thread local instead. | ||
# TODO: Work with Pyramid devs to figure out if there is a better way to support | ||
# the worklow we are using hereor not. | ||
request = get_current_request() | ||
|
||
# Our request could possibly be a None, if there isn't an active request, in | ||
# that case we're going to always deny, because without a request, we can't | ||
# determine if this request is authorized or not. | ||
if request is None: | ||
return Denied("There was no active request.") | ||
|
||
# Re-extract our Macaroon from the request, it sucks to have to do this work | ||
# twice, but I believe it is inevitable unless we pass the Macaroon back as | ||
# a principal-- which doesn't seem to be the right fit for it. | ||
macaroon = extract_http_macaroon(request) | ||
|
||
# This logic will only happen on requests that are being authenticated with | ||
# Macaroons. Any other request will just fall back to the standard Authorization | ||
# policy. | ||
if macaroon is not None: | ||
macaroon_service = request.find_service(IMacaroonService, context=None) | ||
|
||
try: | ||
macaroon_service.verify(macaroon) | ||
except InvalidMacaroon as exc: | ||
return Denied(f"The supplied token was invalid: {str(exc)!r}") | ||
|
||
# If our Macaroon is verified, then we'll pass this request to our underlying | ||
# Authorization policy, so it can handle it's own authorization logic on | ||
# the prinicpal. | ||
return self.policy.permits(context, principals, permission) | ||
|
||
def principals_allowed_by_permission(self, context, permission): | ||
# We just dispatch this, because Macaroons don't restrict what principals are | ||
# allowed by a particular permission, they just restrict specific requests | ||
# to not have that permission. | ||
return self.policy.principals_allowed_by_permission(context, permission) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
# 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 zope.interface import Interface | ||
|
||
|
||
class IMacaroonService(Interface): | ||
def find_userid(macaroon): | ||
""" | ||
Return the id of the user associated with the given macaroon. | ||
""" | ||
|
||
def verify(macaroon): | ||
""" | ||
Verify the given macaroon. | ||
""" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
# 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. | ||
|
||
import os | ||
|
||
import pymacaroons | ||
|
||
from sqlalchemy import Column, ForeignKey, DateTime, String, LargeBinary | ||
from sqlalchemy import orm, sql | ||
from sqlalchemy.dialects.postgresql import JSONB, UUID | ||
|
||
from warehouse import db | ||
from warehouse.accounts.models import User | ||
|
||
|
||
def _generate_key(): | ||
return os.urandom(32) | ||
|
||
|
||
class Macaroon(db.Model): | ||
|
||
__tablename__ = "macaroons" | ||
|
||
# All of our Macaroons belong to a specific user, because a caveat-less | ||
# Macaroon should act the same as their password does, instead of as a | ||
# global permission to upload files. | ||
user_id = Column(UUID(as_uuid=True), ForeignKey("accounts_user.id"), nullable=False) | ||
user = orm.relationship(User) | ||
|
||
# Store some information about the Macaroon to give users some mechanism | ||
# to differentiate between them. | ||
description = Column(String(100), nullable=False, server_default="") | ||
created = Column(DateTime, nullable=False, server_default=sql.func.now()) | ||
last_used = Column(DateTime, nullable=True) | ||
|
||
# We'll store the caveats that were added to the Macaroon during generation | ||
# to allow users to see in their management UI what the total possible | ||
# scope of their macaroon is. | ||
caveats = Column(JSONB, nullable=False, server_default=sql.text("'{}'")) | ||
|
||
# It might be better to move this default into the database, that way we | ||
# make it less likely that something does it incorrectly (since the | ||
# default would be to generate a random key). However, it appears the | ||
# PostgreSQL pgcrypto extension uses OpenSSL RAND_bytes if available | ||
# instead of urandom. This is less than optimal, and we would generally | ||
# prefer to just always use usrandom. Thus we'll do this ourselves here | ||
# in our application. | ||
key = Column(LargeBinary, nullable=False, default=_generate_key) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
# 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. | ||
|
||
import pymacaroons | ||
|
||
from sqlalchemy.orm import joinedload | ||
from sqlalchemy.orm.exc import NoResultFound | ||
from zope.interface import implementer | ||
|
||
from warehouse.macaroons.interfaces import IMacaroonService | ||
from warehouse.macaroons.models import Macaroon as Macaroon | ||
|
||
|
||
class InvalidMacaroon(Exception): | ||
... | ||
|
||
|
||
@implementer(IMacaroonService) | ||
class DatabaseMacaroonService: | ||
def __init__(self, db_session): | ||
self.db = db_session | ||
|
||
def _get_record_from_db(self, macaroon): | ||
# TODO: Better handle parsing the identifier here. | ||
macaroon_id = macaroon.identifier.split()[1].split(b":")[1].decode("utf8") | ||
|
||
try: | ||
dm = ( | ||
self.db.query(Macaroon) | ||
.options(joinedload("user")) | ||
.filter(Macaroon.id == macaroon_id) | ||
.one() | ||
) | ||
except NoResultFound: | ||
return | ||
|
||
return dm | ||
|
||
def find_userid(self, macaroon): | ||
m = pymacaroons.Macaroon.deserialize(macaroon) | ||
dm = self._get_record_from_db(m) | ||
|
||
return None if dm is None else dm.user.id | ||
|
||
def verify(self, macaroon): | ||
m = pymacaroons.Macaroon.deserialize(macaroon) | ||
dm = self._get_record_from_db(m) | ||
|
||
if dm is None: | ||
raise InvalidMacaroon | ||
|
||
verifier = pymacaroons.Verifier() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We would need to implement our verifiers here. We can add arguments to this function like the resource we're operating on or what permission this request is trying to use to pass in values from the outside world, in order to implement something that depends on the current request. |
||
|
||
if not verifier.verify(m, dm.key): | ||
raise InvalidMacaroon | ||
else: | ||
return True | ||
|
||
|
||
def database_macaroon_factory(context, request): | ||
return DatabaseMacaroonService(request.db) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd expect to see
Authorization: Bearer ABCD
and another layer dispatch on the format of the token.Is there a spec for new credential type?