Skip to content

Commit

Permalink
Merge pull request #163 from ahopkins/dev
Browse files Browse the repository at this point in the history
Version 1.3.2 - 2019-05-16
  • Loading branch information
ahopkins committed May 16, 2019
2 parents cf4890e + 71c4467 commit 75e571b
Show file tree
Hide file tree
Showing 19 changed files with 293 additions and 47 deletions.
7 changes: 7 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ help:
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

clean:
find . ! -path "./.eggs/*" -name "*.pyc" -exec rm {} \;
find . ! -path "./.eggs/*" -name "*.pyo" -exec rm {} \;
find . ! -path "./.eggs/*" -name ".coverage" -exec rm {} \;
rm -rf build/* > /dev/null 2>&1
rm -rf dist/* > /dev/null 2>&1

test:
python setup.py test

Expand Down
2 changes: 1 addition & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
# The short X.Y version.
version = u"1.3"
# The full version, including alpha/beta/rc tags.
release = u"1.3.1"
release = u"1.3.2"

# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
Expand Down
8 changes: 8 additions & 0 deletions docs/source/pages/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ Changelog

The format is based on `Keep a Changelog <http://keepachangelog.com/en/1.0.0/>`_ and this project adheres to `Semantic Versioning <http://semver.org/spec/v2.0.0.html>`_.

++++++++++++++++++++++++++
Version 1.3.2 - 2019-05-16
++++++++++++++++++++++++++

| **Added**
| - Instant configuration into ``scoped`` decorator for inline config changes outside of protected.

++++++++++++++++++++++++++
Version 1.3.1 - 2019-04-25
++++++++++++++++++++++++++
Expand Down
4 changes: 2 additions & 2 deletions docs/source/pages/initialization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ What if we ONLY want the authentication on some subset of our web application? S
app = Sanic()
bp = Blueprint('my_blueprint')
Initialize(app, authenticate=lambda: True)
Initialize(bp, authenticate=lambda: True)
app.blueprint(bp)
.. warning::
Expand All @@ -86,7 +86,7 @@ What if we ONLY want the authentication on some subset of our web application? S

.. note::

If you decide to initialize more than one instance of Sanic JWT (on multiple blueprints, for example), than an access token generated by one will be acceptable on **ALL** your instances unless they have different a ``secret``. You can learn more about how to set that in :doc:`configuration`.
If you decide to initialize more than one instance of Sanic JWT (on multiple blueprints, for example), then an access token generated by one will be acceptable on **ALL** your instances unless they have different a ``secret``. You can learn more about how to set that in :doc:`configuration`.

Under the hood, Sanic JWT creates its own ``Blueprint`` for holding all of the :doc:`endpoints`. If you decide to use your own blueprint (and by all means, feel free to do so!), just know that Sanic JWT will not create its own. When this happens, Sanic JWT instead will attach to the blueprint that you passed to it.

Expand Down
233 changes: 233 additions & 0 deletions example/custom_authentication_cls_complex.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import random
from datetime import datetime

from sanic import Sanic
from sanic.response import json

from sanic_jwt import (
Authentication,
Claim,
Configuration,
exceptions,
Initialize,
Responses,
)


class User:
def __init__(self, _id, username):
self.user_id = _id
self.username = username
self._permakey = None
self._last_login = None

def __repr__(self):
return "User(id='{}')".format(self.user_id)

def to_dict(self):
return {
"user_id": self.user_id,
"username": self.username,
"last_login": self.last_login,
}

@property
def permakey(self):
return self._permakey

@permakey.setter
def permakey(self, value):
self._permakey = value

@property
def last_login(self):
return self._last_login

@last_login.setter
def last_login(self, value):
self._last_login = value


USERS = []
username_table = {u.username: u for u in USERS}
userid_table = {u.user_id: u for u in USERS}


class MyConfig(Configuration):
def get_verify_exp(self, request):
"""
If the request is with the "permakey", then we do not want to check for expiration
"""
return not "permakey" in request.headers


class MyAuthentication(Authentication):
def _verify(
self,
request,
return_payload=False,
verify=True,
raise_missing=False,
request_args=None,
request_kwargs=None,
*args,
**kwargs
):
"""
If there is a "permakey", then we will verify the token by checking the
database. Otherwise, just do the normal verification.
Typically, any method that begins with an underscore in sanic-jwt should
not be touched. In this case, we are trying to break the rules a bit to handle
a unique use case: handle both expirable and non-expirable tokens.
"""

if "permakey" in request.headers:
# Extract the permakey from the headers
permakey = request.headers.get("permakey")

# In production, probably should have some exception handling Here
# in case the permakey is an empty string or some other bad value
payload = self._decode(permakey, verify=verify)

# Sometimes, the application will call _verify(...return_payload=True)
# So, let's make sure to handle this scenario.
if return_payload:
return payload

# Retrieve the user from the database
user_id = payload.get("user_id", None)
user = userid_table.get(user_id)

# If wer cannot find a user, then this method should return
# is_valid == False
# reason == some text for why
# status == some status code, probably a 401
if not user_id or not user:
is_valid = False
reason = "No user found"
status = 401
else:
# After finding a user, make sure the permakey matches,
# or else return a bad status or some other error.
# In production, both this scenario, and the above "No user found"
# scenario should return an identical message and status code.
# This is to prevent your application accidentally
# leaking information about the existence or non-existence of users.
is_valid = user.permakey == permakey
reason = None if is_valid else "Permakey mismatch"
status = 200 if is_valid else 401

return is_valid, status, reason
else:
return super()._verify(
request=request,
return_payload=return_payload,
verify=verify,
raise_missing=raise_missing,
request_args=request_args,
request_kwargs=request_kwargs,
*args,
**kwargs
)

async def authenticate(self, request, *args, **kwargs):
username = request.json.get("username", None)
password = request.json.get("password", None)

if not username or not password:
raise exceptions.AuthenticationFailed(
"Missing username or password."
)

# Here, you would want to try to verify the username and password.
# In this example, we are simply creating a new user if it does not
# exist, and then authenticating the new user.
user = username_table.get(username)
if not user:
user = User(len(USERS) + 1, username)
USERS.append(user)
username_table.update({user.username: user})
userid_table.update({user.user_id: user})

user.permakey = await self.generate_access_token(user)

user.last_login = datetime.utcnow().strftime("%c")

return user

async def retrieve_user(self, request, payload, *args, **kwargs):
if payload:
user_id = payload.get("user_id", None)
return userid_table.get(user_id)

else:
return None


def my_payload_extender(payload, *args, **kwargs):
user_id = payload.get("user_id", None)
user = userid_table.get(user_id)
payload.update({"username": user.username})

return payload


class MyResponses(Responses):
@staticmethod
def extend_authenticate(
request, user=None, access_token=None, refresh_token=None
):
return {"permakey": user.permakey}


class RandomClaim(Claim):
"""
This custom claim is not necessary. It is merely being added so that everytime
that await Authentication.generate_access_token() is being called, it will
provide a different token. It is for illustrative purposes only
"""

key = "rand"

def setup(self, *args, **kwargs):
return random.random()

def verify(self, *args, **kwargs):
return True


# elsewhere in the universe ...
if __name__ == "__main__":
app = Sanic(__name__)

sanicjwt = Initialize(
app,
authentication_class=MyAuthentication,
configuration_class=MyConfig,
# Following settings are for example purposes only
responses_class=MyResponses,
custom_claims=[RandomClaim],
extend_payload=my_payload_extender,
expiration_delta=15,
leeway=0,
)

@app.route("/")
async def helloworld(request):
return json({"hello": "world"})

@app.route("/protected")
@sanicjwt.protected()
async def protected_request(request):
return json({"protected": True})

# this route is for demonstration only

@app.route("/cache")
@sanicjwt.protected()
async def protected_cache(request):
print(USERS)
return json(userid_table)

app.run(debug=True)
2 changes: 1 addition & 1 deletion sanic_jwt/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "1.3.1"
__version__ = "1.3.2"
__author__ = "Adam Hopkins"
__credits__ = "Richard Kuesters"

Expand Down
57 changes: 30 additions & 27 deletions sanic_jwt/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,33 +156,14 @@ async def decorated_function(request, *args, **kwargs):
if request.method == "OPTIONS":
return instance

user_scopes = instance.auth.extract_scopes(request)
override = instance.auth.override_scope_validator
destructure = instance.auth.destructure_scopes
if user_scopes is None:
# If there are no defined scopes in the payload,
# deny access
is_authorized = False
status = 403
reasons = "Invalid scope."

# TODO:
# - add login_redirect_url
raise exceptions.Unauthorized(reasons, status_code=status)

else:
is_authorized = await validate_scopes(
request,
scopes,
user_scopes,
require_all=require_all,
require_all_actions=require_all_actions,
override=override,
destructure=destructure,
request_args=args,
request_kwargs=kwargs,
)
if not is_authorized:
with instant_config(instance, request=request, **kw):
user_scopes = instance.auth.extract_scopes(request)
override = instance.auth.override_scope_validator
destructure = instance.auth.destructure_scopes
if user_scopes is None:
# If there are no defined scopes in the payload,
# deny access
is_authorized = False
status = 403
reasons = "Invalid scope."

Expand All @@ -192,6 +173,28 @@ async def decorated_function(request, *args, **kwargs):
reasons, status_code=status
)

else:
is_authorized = await validate_scopes(
request,
scopes,
user_scopes,
require_all=require_all,
require_all_actions=require_all_actions,
override=override,
destructure=destructure,
request_args=args,
request_kwargs=kwargs,
)
if not is_authorized:
status = 403
reasons = "Invalid scope."

# TODO:
# - add login_redirect_url
raise exceptions.Unauthorized(
reasons, status_code=status
)

# the user is authorized.
# run the handler method and return the response
# NOTE: it's possible to use return await.utils(f, ...) in
Expand Down
3 changes: 1 addition & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,14 @@ def open_local(paths, mode="r", encoding="utf8"):
with open_local(["sanic_jwt", "__init__.py"], encoding="latin1") as fp:
try:
version = re.findall(
r"^__version__ = \"([^']+)\"\r?$", fp.read(), re.M
r"^__version__ = \"([0-9\.]+)\"", fp.read(), re.M
)[0]
except IndexError:
raise RuntimeError("Unable to determine version.")

with open_local(["README.md"]) as rm:
long_description = rm.read()


extras_require = {"docs": ["Sphinx"]}

extras_require["all"] = []
Expand Down
2 changes: 1 addition & 1 deletion tests/test_async_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
"""


import jwt
from sanic import Blueprint, Sanic
from sanic.response import text
from sanic.views import HTTPMethodView

import jwt
import pytest
from sanic_jwt import Authentication, initialize, protected

Expand Down

0 comments on commit 75e571b

Please sign in to comment.