Skip to content

Commit 75e571b

Browse files
authored
Merge pull request #163 from ahopkins/dev
Version 1.3.2 - 2019-05-16
2 parents cf4890e + 71c4467 commit 75e571b

19 files changed

+293
-47
lines changed

Makefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ help:
1515
%: Makefile
1616
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
1717

18+
clean:
19+
find . ! -path "./.eggs/*" -name "*.pyc" -exec rm {} \;
20+
find . ! -path "./.eggs/*" -name "*.pyo" -exec rm {} \;
21+
find . ! -path "./.eggs/*" -name ".coverage" -exec rm {} \;
22+
rm -rf build/* > /dev/null 2>&1
23+
rm -rf dist/* > /dev/null 2>&1
24+
1825
test:
1926
python setup.py test
2027

docs/source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
# The short X.Y version.
5757
version = u"1.3"
5858
# The full version, including alpha/beta/rc tags.
59-
release = u"1.3.1"
59+
release = u"1.3.2"
6060

6161
# The language for content autogenerated by Sphinx. Refer to documentation
6262
# for a list of supported languages.

docs/source/pages/changelog.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ Changelog
44

55
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>`_.
66

7+
++++++++++++++++++++++++++
8+
Version 1.3.2 - 2019-05-16
9+
++++++++++++++++++++++++++
10+
11+
| **Added**
12+
| - Instant configuration into ``scoped`` decorator for inline config changes outside of protected.
13+
14+
715
++++++++++++++++++++++++++
816
Version 1.3.1 - 2019-04-25
917
++++++++++++++++++++++++++

docs/source/pages/initialization.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ What if we ONLY want the authentication on some subset of our web application? S
7777
7878
app = Sanic()
7979
bp = Blueprint('my_blueprint')
80-
Initialize(app, authenticate=lambda: True)
80+
Initialize(bp, authenticate=lambda: True)
8181
app.blueprint(bp)
8282
8383
.. warning::
@@ -86,7 +86,7 @@ What if we ONLY want the authentication on some subset of our web application? S
8686

8787
.. note::
8888

89-
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`.
89+
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`.
9090

9191
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.
9292

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import random
2+
from datetime import datetime
3+
4+
from sanic import Sanic
5+
from sanic.response import json
6+
7+
from sanic_jwt import (
8+
Authentication,
9+
Claim,
10+
Configuration,
11+
exceptions,
12+
Initialize,
13+
Responses,
14+
)
15+
16+
17+
class User:
18+
def __init__(self, _id, username):
19+
self.user_id = _id
20+
self.username = username
21+
self._permakey = None
22+
self._last_login = None
23+
24+
def __repr__(self):
25+
return "User(id='{}')".format(self.user_id)
26+
27+
def to_dict(self):
28+
return {
29+
"user_id": self.user_id,
30+
"username": self.username,
31+
"last_login": self.last_login,
32+
}
33+
34+
@property
35+
def permakey(self):
36+
return self._permakey
37+
38+
@permakey.setter
39+
def permakey(self, value):
40+
self._permakey = value
41+
42+
@property
43+
def last_login(self):
44+
return self._last_login
45+
46+
@last_login.setter
47+
def last_login(self, value):
48+
self._last_login = value
49+
50+
51+
USERS = []
52+
username_table = {u.username: u for u in USERS}
53+
userid_table = {u.user_id: u for u in USERS}
54+
55+
56+
class MyConfig(Configuration):
57+
def get_verify_exp(self, request):
58+
"""
59+
If the request is with the "permakey", then we do not want to check for expiration
60+
"""
61+
return not "permakey" in request.headers
62+
63+
64+
class MyAuthentication(Authentication):
65+
def _verify(
66+
self,
67+
request,
68+
return_payload=False,
69+
verify=True,
70+
raise_missing=False,
71+
request_args=None,
72+
request_kwargs=None,
73+
*args,
74+
**kwargs
75+
):
76+
"""
77+
If there is a "permakey", then we will verify the token by checking the
78+
database. Otherwise, just do the normal verification.
79+
80+
Typically, any method that begins with an underscore in sanic-jwt should
81+
not be touched. In this case, we are trying to break the rules a bit to handle
82+
a unique use case: handle both expirable and non-expirable tokens.
83+
"""
84+
85+
if "permakey" in request.headers:
86+
# Extract the permakey from the headers
87+
permakey = request.headers.get("permakey")
88+
89+
# In production, probably should have some exception handling Here
90+
# in case the permakey is an empty string or some other bad value
91+
payload = self._decode(permakey, verify=verify)
92+
93+
# Sometimes, the application will call _verify(...return_payload=True)
94+
# So, let's make sure to handle this scenario.
95+
if return_payload:
96+
return payload
97+
98+
# Retrieve the user from the database
99+
user_id = payload.get("user_id", None)
100+
user = userid_table.get(user_id)
101+
102+
# If wer cannot find a user, then this method should return
103+
# is_valid == False
104+
# reason == some text for why
105+
# status == some status code, probably a 401
106+
if not user_id or not user:
107+
is_valid = False
108+
reason = "No user found"
109+
status = 401
110+
else:
111+
# After finding a user, make sure the permakey matches,
112+
# or else return a bad status or some other error.
113+
# In production, both this scenario, and the above "No user found"
114+
# scenario should return an identical message and status code.
115+
# This is to prevent your application accidentally
116+
# leaking information about the existence or non-existence of users.
117+
is_valid = user.permakey == permakey
118+
reason = None if is_valid else "Permakey mismatch"
119+
status = 200 if is_valid else 401
120+
121+
return is_valid, status, reason
122+
else:
123+
return super()._verify(
124+
request=request,
125+
return_payload=return_payload,
126+
verify=verify,
127+
raise_missing=raise_missing,
128+
request_args=request_args,
129+
request_kwargs=request_kwargs,
130+
*args,
131+
**kwargs
132+
)
133+
134+
async def authenticate(self, request, *args, **kwargs):
135+
username = request.json.get("username", None)
136+
password = request.json.get("password", None)
137+
138+
if not username or not password:
139+
raise exceptions.AuthenticationFailed(
140+
"Missing username or password."
141+
)
142+
143+
# Here, you would want to try to verify the username and password.
144+
# In this example, we are simply creating a new user if it does not
145+
# exist, and then authenticating the new user.
146+
user = username_table.get(username)
147+
if not user:
148+
user = User(len(USERS) + 1, username)
149+
USERS.append(user)
150+
username_table.update({user.username: user})
151+
userid_table.update({user.user_id: user})
152+
153+
user.permakey = await self.generate_access_token(user)
154+
155+
user.last_login = datetime.utcnow().strftime("%c")
156+
157+
return user
158+
159+
async def retrieve_user(self, request, payload, *args, **kwargs):
160+
if payload:
161+
user_id = payload.get("user_id", None)
162+
return userid_table.get(user_id)
163+
164+
else:
165+
return None
166+
167+
168+
def my_payload_extender(payload, *args, **kwargs):
169+
user_id = payload.get("user_id", None)
170+
user = userid_table.get(user_id)
171+
payload.update({"username": user.username})
172+
173+
return payload
174+
175+
176+
class MyResponses(Responses):
177+
@staticmethod
178+
def extend_authenticate(
179+
request, user=None, access_token=None, refresh_token=None
180+
):
181+
return {"permakey": user.permakey}
182+
183+
184+
class RandomClaim(Claim):
185+
"""
186+
This custom claim is not necessary. It is merely being added so that everytime
187+
that await Authentication.generate_access_token() is being called, it will
188+
provide a different token. It is for illustrative purposes only
189+
"""
190+
191+
key = "rand"
192+
193+
def setup(self, *args, **kwargs):
194+
return random.random()
195+
196+
def verify(self, *args, **kwargs):
197+
return True
198+
199+
200+
# elsewhere in the universe ...
201+
if __name__ == "__main__":
202+
app = Sanic(__name__)
203+
204+
sanicjwt = Initialize(
205+
app,
206+
authentication_class=MyAuthentication,
207+
configuration_class=MyConfig,
208+
# Following settings are for example purposes only
209+
responses_class=MyResponses,
210+
custom_claims=[RandomClaim],
211+
extend_payload=my_payload_extender,
212+
expiration_delta=15,
213+
leeway=0,
214+
)
215+
216+
@app.route("/")
217+
async def helloworld(request):
218+
return json({"hello": "world"})
219+
220+
@app.route("/protected")
221+
@sanicjwt.protected()
222+
async def protected_request(request):
223+
return json({"protected": True})
224+
225+
# this route is for demonstration only
226+
227+
@app.route("/cache")
228+
@sanicjwt.protected()
229+
async def protected_cache(request):
230+
print(USERS)
231+
return json(userid_table)
232+
233+
app.run(debug=True)

sanic_jwt/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "1.3.1"
1+
__version__ = "1.3.2"
22
__author__ = "Adam Hopkins"
33
__credits__ = "Richard Kuesters"
44

sanic_jwt/decorators.py

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -156,33 +156,14 @@ async def decorated_function(request, *args, **kwargs):
156156
if request.method == "OPTIONS":
157157
return instance
158158

159-
user_scopes = instance.auth.extract_scopes(request)
160-
override = instance.auth.override_scope_validator
161-
destructure = instance.auth.destructure_scopes
162-
if user_scopes is None:
163-
# If there are no defined scopes in the payload,
164-
# deny access
165-
is_authorized = False
166-
status = 403
167-
reasons = "Invalid scope."
168-
169-
# TODO:
170-
# - add login_redirect_url
171-
raise exceptions.Unauthorized(reasons, status_code=status)
172-
173-
else:
174-
is_authorized = await validate_scopes(
175-
request,
176-
scopes,
177-
user_scopes,
178-
require_all=require_all,
179-
require_all_actions=require_all_actions,
180-
override=override,
181-
destructure=destructure,
182-
request_args=args,
183-
request_kwargs=kwargs,
184-
)
185-
if not is_authorized:
159+
with instant_config(instance, request=request, **kw):
160+
user_scopes = instance.auth.extract_scopes(request)
161+
override = instance.auth.override_scope_validator
162+
destructure = instance.auth.destructure_scopes
163+
if user_scopes is None:
164+
# If there are no defined scopes in the payload,
165+
# deny access
166+
is_authorized = False
186167
status = 403
187168
reasons = "Invalid scope."
188169

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

176+
else:
177+
is_authorized = await validate_scopes(
178+
request,
179+
scopes,
180+
user_scopes,
181+
require_all=require_all,
182+
require_all_actions=require_all_actions,
183+
override=override,
184+
destructure=destructure,
185+
request_args=args,
186+
request_kwargs=kwargs,
187+
)
188+
if not is_authorized:
189+
status = 403
190+
reasons = "Invalid scope."
191+
192+
# TODO:
193+
# - add login_redirect_url
194+
raise exceptions.Unauthorized(
195+
reasons, status_code=status
196+
)
197+
195198
# the user is authorized.
196199
# run the handler method and return the response
197200
# NOTE: it's possible to use return await.utils(f, ...) in

setup.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,14 @@ def open_local(paths, mode="r", encoding="utf8"):
1414
with open_local(["sanic_jwt", "__init__.py"], encoding="latin1") as fp:
1515
try:
1616
version = re.findall(
17-
r"^__version__ = \"([^']+)\"\r?$", fp.read(), re.M
17+
r"^__version__ = \"([0-9\.]+)\"", fp.read(), re.M
1818
)[0]
1919
except IndexError:
2020
raise RuntimeError("Unable to determine version.")
2121

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

25-
2625
extras_require = {"docs": ["Sphinx"]}
2726

2827
extras_require["all"] = []

tests/test_async_options.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
"""
55

66

7-
import jwt
87
from sanic import Blueprint, Sanic
98
from sanic.response import text
109
from sanic.views import HTTPMethodView
1110

11+
import jwt
1212
import pytest
1313
from sanic_jwt import Authentication, initialize, protected
1414

0 commit comments

Comments
 (0)