diff --git a/gluon/contrib/web2py_jwt.py b/gluon/contrib/web2py_jwt.py deleted file mode 100644 index 00aac357..00000000 --- a/gluon/contrib/web2py_jwt.py +++ /dev/null @@ -1,304 +0,0 @@ -# -*- coding: utf-8 -*- -import datetime -import uuid -import time -from gluon.serializers import json_parser -import base64 -import hmac -import hashlib -from gluon.storage import Storage -from gluon.utils import web2py_uuid -from gluon import current -from gluon.http import HTTP - - -class Web2pyJwt(object): - - """ - If left externally, this needs the usual "singleton" approach. - Given I (we) don't know if to include in auth yet, let's stick to basics. - - Args: - - secret_key: the secret. Without salting, an attacker knowing this can impersonate - any user - - algorithm : uses as they are in the JWT specs, HS256, HS384 or HS512 basically means - signing with HMAC with a 256, 284 or 512bit hash - - verify_expiration : verifies the expiration checking the exp claim - - leeway: allow n seconds of skew when checking for token expiration - - expiration : how many seconds a token may be valid - - allow_refresh: enable the machinery to get a refreshed token passing a not-already-expired - token - - refresh_expiration_delta: to avoid continous refresh of the token - - header_prefix : self-explanatory. "JWT" and "Bearer" seems to be the emerging standards - - jwt_add_header: a dict holding additional mappings to the header. by default only alg and typ are filled - - user_param: the name of the parameter holding the username when requesting a token. Can be useful, e.g, for - email-based authentication, with "email" as a parameter - - pass_param: same as above, but for the password - - realm: self-explanatory - - salt: can be static or a function that takes the payload as an argument. - Example: - def mysalt(payload): - return payload['hmac_key'].split('-')[0] - - additional_payload: can be a dict to merge with the payload or a function that takes - the payload as input and returns the modified payload - Example: - def myadditional_payload(payload): - payload['my_name_is'] = 'bond,james bond' - return payload - - before_authorization: can be a callable that takes the deserialized token (a dict) as input. - Gets called right after signature verification but before the actual - authorization takes place. You can raise with HTTP a proper error message - Example: - def mybefore_authorization(tokend): - if not tokend['my_name_is'] == 'bond,james bond': - raise HTTP(400, u'Invalid JWT my_name_is claim') - - max_header_length: check max length to avoid load()ing unusually large tokens (could mean crafted, e.g. in a DDoS.) - - Basic Usage: - in models (or the controller needing it) - - myjwt = Web2pyJwt('secret', auth) - - in the controller issuing tokens - - def login_and_take_token(): - return myjwt.jwt_token_manager() - - A call then to /app/controller/login_and_take_token/auth with username and password returns the token - A call to /app/controller/login_and_take_token/refresh with the original token returns the refreshed token - - To protect a function with JWT - - @myjwt.requires_jwt() - @auth.requires_login() - def protected(): - return '%s$%s' % (request.now, auth.user_id) - - """ - - def __init__(self, secret_key, - auth, - algorithm='HS256', - verify_expiration=True, - leeway=30, - expiration=60 * 5, - allow_refresh=True, - refresh_expiration_delta=60 * 60, - header_prefix='Bearer', - jwt_add_header=None, - user_param='username', - pass_param='password', - realm='Login required', - salt=None, - additional_payload=None, - before_authorization=None, - max_header_length=4*1024, - ): - self.secret_key = secret_key - self.auth = auth - self.algorithm = algorithm - if self.algorithm not in ('HS256', 'HS384', 'HS512'): - raise NotImplementedError('Algoritm %s not allowed' % algorithm) - self.verify_expiration = verify_expiration - self.leeway = leeway - self.expiration = expiration - self.allow_refresh = allow_refresh - self.refresh_expiration_delta = refresh_expiration_delta - self.header_prefix = header_prefix - self.jwt_add_header = jwt_add_header or {} - base_header = {'alg': self.algorithm, 'typ': 'JWT'} - for k, v in self.jwt_add_header.iteritems(): - base_header[k] = v - self.cached_b64h = self.jwt_b64e(json_parser.dumps(base_header)) - digestmod_mapping = { - 'HS256': hashlib.sha256, - 'HS384': hashlib.sha384, - 'HS512': hashlib.sha512 - } - self.digestmod = digestmod_mapping[algorithm] - self.user_param = user_param - self.pass_param = pass_param - self.realm = realm - self.salt = salt - self.additional_payload = additional_payload - self.before_authorization = before_authorization - self.max_header_length = max_header_length - print 'initialized' - - @staticmethod - def jwt_b64e(string): - if isinstance(string, unicode): - string = string.encode('uft-8', 'strict') - return base64.urlsafe_b64encode(string).strip(b'=') - - @staticmethod - def jwt_b64d(string): - """base64 decodes a single bytestring (and is tolerant to getting - called with a unicode string). - The result is also a bytestring. - """ - if isinstance(string, unicode): - string = string.encode('ascii', 'ignore') - return base64.urlsafe_b64decode(string + '=' * (-len(string) % 4)) - - def generate_token(self, payload): - secret = self.secret_key - if self.salt: - if callable(self.salt): - secret = "%s$%s" % (secret, self.salt(payload)) - else: - secret = "%s$%s" % (secret, self.salt) - if isinstance(secret, unicode): - secret = secret.encode('ascii', 'ignore') - b64h = self.cached_b64h - b64p = self.jwt_b64e(json_parser.dumps(payload)) - jbody = b64h + '.' + b64p - mauth = hmac.new(key=secret, msg=jbody, digestmod=self.digestmod) - jsign = self.jwt_b64e(mauth.digest()) - return jbody + '.' + jsign - - def verify_signature(self, body, signature, secret): - mauth = hmac.new(key=secret, msg=body, digestmod=self.digestmod) - return hmac.compare_digest(self.jwt_b64e(mauth.digest()), signature) - - def load_token(self, token): - if isinstance(token, unicode): - token = token.encode('utf-8', 'strict') - body, sig = token.rsplit('.', 1) - b64h, b64b = body.split('.', 1) - if b64h != self.cached_b64h: - # header not the same - raise HTTP(400, u'Invalid JWT Header') - secret = self.secret_key - tokend = json_parser.loads(self.jwt_b64d(b64b)) - if self.salt: - if callable(self.salt): - secret = "%s$%s" % (secret, self.salt(tokend)) - else: - secret = "%s$%s" % (secret, self.salt) - if isinstance(secret, unicode): - secret = secret.encode('ascii', 'ignore') - if not self.verify_signature(body, sig, secret): - # signature verification failed - raise HTTP(400, u'Token signature is invalid') - if self.verify_expiration: - now = time.mktime(datetime.datetime.utcnow().timetuple()) - if tokend['exp'] + self.leeway < now: - raise HTTP(400, u'Token is expired') - if callable(self.before_authorization): - self.before_authorization(tokend) - return tokend - - def serialize_auth_session(self, session_auth): - """ - As bad as it sounds, as long as this is rarely used (vs using the token) - this is the faster method, even if we ditch session in jwt_token_manager(). - We (mis)use the heavy default auth mechanism to avoid any further computation, - while sticking to a somewhat-stable Auth API. - """ - now = time.mktime(datetime.datetime.utcnow().timetuple()) - expires = now + self.expiration - payload = dict( - hmac_key=session_auth['hmac_key'], - user_groups=session_auth['user_groups'], - user=session_auth['user'].as_dict(), - iat=now, - exp=expires - ) - return payload - - def refresh_token(self, orig_payload): - now = time.mktime(datetime.datetime.utcnow().timetuple()) - if self.verify_expiration: - orig_exp = orig_payload['exp'] - if orig_exp + self.leeway < now: - # token already expired, can't be used for refresh - raise HTTP(400, u'Token already expired') - orig_iat = orig_payload.get('orig_iat') or orig_payload['iat'] - if orig_iat + self.refresh_expiration_delta < now: - # refreshed too long ago - raise HTTP(400, u'Token issued too long ago') - expires = now + self.refresh_expiration_delta - orig_payload.update( - orig_iat=orig_iat, - iat=now, - exp=expires, - hmac_key=web2py_uuid() - ) - self.alter_payload(orig_payload) - return orig_payload - - def alter_payload(self, payload): - if self.additional_payload: - if callable(self.additional_payload): - payload = self.additional_payload(payload) - elif isinstance(self.additional_payload, dict): - payload.update(self.additional_payload) - return payload - - def jwt_token_manager(self): - """ - The part that issues (and refreshes) tokens. - Used in a controller, given myjwt is the istantiated class, as - - def api_auth(): - return myjwt.jwt_token_manager() - - Then, a call to /app/c/api_auth/auth with username and password - returns a token, while /app/c/api_auth/refresh with the current token - issues another token - """ - request = current.request - # forget and unlock response - if request.args(0) == 'auth': - current.session.forget(current.response) - username = request.vars[self.user_param] - password = request.vars[self.pass_param] - valid_user = self.auth.login_bare(username, password) - if valid_user: - payload = self.serialize_auth_session(current.session.auth) - self.alter_payload(payload) - return self.generate_token(payload) - else: - raise HTTP( - 401, u'Not Authorized', - **{'WWW-Authenticate': u'JWT realm="%s"' % self.realm}) - elif request.args(0) == 'refresh': - if not self.allow_refresh: - raise HTTP(403, u'Refreshing token is not allowed') - token = request.vars.token - tokend = self.load_token(token) - # verification can fail here - refreshed = self.refresh_token(tokend) - return self.generate_token(refreshed) - - def inject_token(self, tokend): - """ - The real deal, not touching the db but still logging-in the user - """ - self.auth.user = Storage(tokend['user']) - self.auth.user_groups = tokend['user_groups'] - self.auth.hmac_key = tokend['hmac_key'] - - def requires_jwt(self, otherwise=None): - """ - The validator that checks for the header or the - _token var - """ - request = current.request - token_in_header = request.env.http_authorization - if token_in_header: - parts = token_in_header.split() - if parts[0].lower() != self.header_prefix.lower(): - raise HTTP(400, u'Invalid JWT header') - elif len(parts) == 1: - raise HTTP(400, u'Invalid JWT header, missing token') - elif len(parts) > 2: - raise HTTP(400, 'Invalid JWT header, token contains spaces') - token = parts[1] - else: - token = request.vars._token - if token and len(token) < self.max_header_length: - tokend = self.load_token(token) - self.inject_token(tokend) - return self.auth.requires(True, otherwise=otherwise) diff --git a/gluon/tools.py b/gluon/tools.py index f43737be..62f52f6b 100644 --- a/gluon/tools.py +++ b/gluon/tools.py @@ -32,8 +32,12 @@ import cStringIO import ConfigParser import email.utils import random +import hmac +import hashlib +import json from email import MIMEBase, MIMEMultipart, MIMEText, Encoders, Header, message_from_string, Charset +from gluon.serializers import json_parser from gluon.contenttype import contenttype from gluon.storage import Storage, StorageList, Settings, Messages from gluon.utils import web2py_uuid @@ -1136,6 +1140,304 @@ def addrow(form, a, b, c, style, _id, position=-1): TD(c, _class='w2p_fc'), _id=_id)) +class AuthJWT(object): + + """ + If left externally, this needs the usual "singleton" approach. + Given I (we) don't know if to include in auth yet, let's stick to basics. + + Args: + - secret_key: the secret. Without salting, an attacker knowing this can impersonate + any user + - algorithm : uses as they are in the JWT specs, HS256, HS384 or HS512 basically means + signing with HMAC with a 256, 284 or 512bit hash + - verify_expiration : verifies the expiration checking the exp claim + - leeway: allow n seconds of skew when checking for token expiration + - expiration : how many seconds a token may be valid + - allow_refresh: enable the machinery to get a refreshed token passing a not-already-expired + token + - refresh_expiration_delta: to avoid continous refresh of the token + - header_prefix : self-explanatory. "JWT" and "Bearer" seems to be the emerging standards + - jwt_add_header: a dict holding additional mappings to the header. by default only alg and typ are filled + - user_param: the name of the parameter holding the username when requesting a token. Can be useful, e.g, for + email-based authentication, with "email" as a parameter + - pass_param: same as above, but for the password + - realm: self-explanatory + - salt: can be static or a function that takes the payload as an argument. + Example: + def mysalt(payload): + return payload['hmac_key'].split('-')[0] + - additional_payload: can be a dict to merge with the payload or a function that takes + the payload as input and returns the modified payload + Example: + def myadditional_payload(payload): + payload['my_name_is'] = 'bond,james bond' + return payload + - before_authorization: can be a callable that takes the deserialized token (a dict) as input. + Gets called right after signature verification but before the actual + authorization takes place. You can raise with HTTP a proper error message + Example: + def mybefore_authorization(tokend): + if not tokend['my_name_is'] == 'bond,james bond': + raise HTTP(400, u'Invalid JWT my_name_is claim') + - max_header_length: check max length to avoid load()ing unusually large tokens (could mean crafted, e.g. in a DDoS.) + + Basic Usage: + in models (or the controller needing it) + + myjwt = AuthJWT(auth, secret_key='secret') + + in the controller issuing tokens + + def login_and_take_token(): + return myjwt.jwt_token_manager() + + A call then to /app/controller/login_and_take_token/auth with username and password returns the token + A call to /app/controller/login_and_take_token/refresh with the original token returns the refreshed token + + To protect a function with JWT + + @myjwt.allows_jwt() + @auth.requires_login() + def protected(): + return '%s$%s' % (request.now, auth.user_id) + + """ + + def __init__(self, + auth, + secret_key, + algorithm='HS256', + verify_expiration=True, + leeway=30, + expiration=60 * 5, + allow_refresh=True, + refresh_expiration_delta=60 * 60, + header_prefix='Bearer', + jwt_add_header=None, + user_param='username', + pass_param='password', + realm='Login required', + salt=None, + additional_payload=None, + before_authorization=None, + max_header_length=4*1024, + ): + self.secret_key = secret_key + self.auth = auth + self.algorithm = algorithm + if self.algorithm not in ('HS256', 'HS384', 'HS512'): + raise NotImplementedError('Algoritm %s not allowed' % algorithm) + self.verify_expiration = verify_expiration + self.leeway = leeway + self.expiration = expiration + self.allow_refresh = allow_refresh + self.refresh_expiration_delta = refresh_expiration_delta + self.header_prefix = header_prefix + self.jwt_add_header = jwt_add_header or {} + base_header = {'alg': self.algorithm, 'typ': 'JWT'} + for k, v in self.jwt_add_header.iteritems(): + base_header[k] = v + self.cached_b64h = self.jwt_b64e(json_parser.dumps(base_header)) + digestmod_mapping = { + 'HS256': hashlib.sha256, + 'HS384': hashlib.sha384, + 'HS512': hashlib.sha512 + } + self.digestmod = digestmod_mapping[algorithm] + self.user_param = user_param + self.pass_param = pass_param + self.realm = realm + self.salt = salt + self.additional_payload = additional_payload + self.before_authorization = before_authorization + self.max_header_length = max_header_length + + @staticmethod + def jwt_b64e(string): + if isinstance(string, unicode): + string = string.encode('uft-8', 'strict') + return base64.urlsafe_b64encode(string).strip(b'=') + + @staticmethod + def jwt_b64d(string): + """base64 decodes a single bytestring (and is tolerant to getting + called with a unicode string). + The result is also a bytestring. + """ + if isinstance(string, unicode): + string = string.encode('ascii', 'ignore') + return base64.urlsafe_b64decode(string + '=' * (-len(string) % 4)) + + def generate_token(self, payload): + secret = self.secret_key + if self.salt: + if callable(self.salt): + secret = "%s$%s" % (secret, self.salt(payload)) + else: + secret = "%s$%s" % (secret, self.salt) + if isinstance(secret, unicode): + secret = secret.encode('ascii', 'ignore') + b64h = self.cached_b64h + b64p = self.jwt_b64e(json_parser.dumps(payload)) + jbody = b64h + '.' + b64p + mauth = hmac.new(key=secret, msg=jbody, digestmod=self.digestmod) + jsign = self.jwt_b64e(mauth.digest()) + return jbody + '.' + jsign + + def verify_signature(self, body, signature, secret): + mauth = hmac.new(key=secret, msg=body, digestmod=self.digestmod) + return hmac.compare_digest(self.jwt_b64e(mauth.digest()), signature) + + def load_token(self, token): + if isinstance(token, unicode): + token = token.encode('utf-8', 'strict') + body, sig = token.rsplit('.', 1) + b64h, b64b = body.split('.', 1) + if b64h != self.cached_b64h: + # header not the same + raise HTTP(400, u'Invalid JWT Header') + secret = self.secret_key + tokend = json_parser.loads(self.jwt_b64d(b64b)) + if self.salt: + if callable(self.salt): + secret = "%s$%s" % (secret, self.salt(tokend)) + else: + secret = "%s$%s" % (secret, self.salt) + if isinstance(secret, unicode): + secret = secret.encode('ascii', 'ignore') + if not self.verify_signature(body, sig, secret): + # signature verification failed + raise HTTP(400, u'Token signature is invalid') + if self.verify_expiration: + now = time.mktime(datetime.datetime.utcnow().timetuple()) + if tokend['exp'] + self.leeway < now: + raise HTTP(400, u'Token is expired') + if callable(self.before_authorization): + self.before_authorization(tokend) + return tokend + + def serialize_auth_session(self, session_auth): + """ + As bad as it sounds, as long as this is rarely used (vs using the token) + this is the faster method, even if we ditch session in jwt_token_manager(). + We (mis)use the heavy default auth mechanism to avoid any further computation, + while sticking to a somewhat-stable Auth API. + """ + now = time.mktime(datetime.datetime.utcnow().timetuple()) + expires = now + self.expiration + payload = dict( + hmac_key=session_auth['hmac_key'], + user_groups=session_auth['user_groups'], + user=session_auth['user'].as_dict(), + iat=now, + exp=expires + ) + return payload + + def refresh_token(self, orig_payload): + now = time.mktime(datetime.datetime.utcnow().timetuple()) + if self.verify_expiration: + orig_exp = orig_payload['exp'] + if orig_exp + self.leeway < now: + # token already expired, can't be used for refresh + raise HTTP(400, u'Token already expired') + orig_iat = orig_payload.get('orig_iat') or orig_payload['iat'] + if orig_iat + self.refresh_expiration_delta < now: + # refreshed too long ago + raise HTTP(400, u'Token issued too long ago') + expires = now + self.refresh_expiration_delta + orig_payload.update( + orig_iat=orig_iat, + iat=now, + exp=expires, + hmac_key=web2py_uuid() + ) + self.alter_payload(orig_payload) + return orig_payload + + def alter_payload(self, payload): + if self.additional_payload: + if callable(self.additional_payload): + payload = self.additional_payload(payload) + elif isinstance(self.additional_payload, dict): + payload.update(self.additional_payload) + return payload + + def jwt_token_manager(self): + """ + The part that issues (and refreshes) tokens. + Used in a controller, given myjwt is the istantiated class, as + + def api_auth(): + return myjwt.jwt_token_manager() + + Then, a call to /app/c/api_auth/auth with username and password + returns a token, while /app/c/api_auth/refresh with the current token + issues another token + """ + request = current.request + response = current.response + session = current.session + # forget and unlock response + if request.vars.token: + if not self.allow_refresh: + raise HTTP(403, u'Refreshing token is not allowed') + token = request.vars.token + tokend = self.load_token(token) + # verification can fail here + refreshed = self.refresh_token(tokend) + ret = {'token':self.generate_token(refreshed)} + elif self.user_param in request.vars and self.pass_param in request.vars: + session.forget(response) + username = request.vars[self.user_param] + password = request.vars[self.pass_param] + valid_user = self.auth.login_bare(username, password) + if valid_user: + payload = self.serialize_auth_session(current.session.auth) + self.alter_payload(payload) + ret = {'token':self.generate_token(payload)} + else: + raise HTTP( + 401, u'Not Authorized', + **{'WWW-Authenticate': u'JWT realm="%s"' % self.realm}) + else: + raise HTTP(400, u'Must pass token for refresh or username and password for login') + response.headers['content-type'] = 'application/json' + return json.dumps(ret) + + def inject_token(self, tokend): + """ + The real deal, not touching the db but still logging-in the user + """ + self.auth.user = Storage(tokend['user']) + self.auth.user_groups = tokend['user_groups'] + self.auth.hmac_key = tokend['hmac_key'] + + def allows_jwt(self, otherwise=None): + """ + The validator that checks for the header or the + _token var + """ + request = current.request + token_in_header = request.env.http_authorization + if token_in_header: + parts = token_in_header.split() + if parts[0].lower() != self.header_prefix.lower(): + raise HTTP(400, u'Invalid JWT header') + elif len(parts) == 1: + raise HTTP(400, u'Invalid JWT header, missing token') + elif len(parts) > 2: + raise HTTP(400, 'Invalid JWT header, token contains spaces') + token = parts[1] + else: + token = request.vars._token + if token and len(token) < self.max_header_length: + tokend = self.load_token(token) + self.inject_token(tokend) + return self.auth.requires(True, otherwise=otherwise) + + class Auth(object): default_settings = dict( @@ -1424,7 +1726,7 @@ class Auth(object): hmac_key=None, controller='default', function='user', cas_provider=None, signature=True, secure=False, csrf_prevention=True, propagate_extension=None, - url_index=None): + url_index=None, jwt=None): ## next two lines for backward compatibility if not db and environment and isinstance(environment, DAL): @@ -1544,6 +1846,8 @@ class Auth(object): self.define_signature() else: self.signature = None + + self.jwt_handler = jwt and AuthJWT(self, **jwt) def get_vars_next(self): next = current.request.vars._next @@ -1612,7 +1916,7 @@ class Auth(object): 'reset_password', 'request_reset_password', 'change_password', 'profile', 'groups', 'impersonate', 'not_authorized', 'confirm_registration', - 'bulk_register','manage_tokens'): + 'bulk_register','manage_tokens','jwt'): if len(request.args) >= 2 and args[0] == 'impersonate': return getattr(self, args[0])(request.args[1]) else: @@ -1627,7 +1931,7 @@ class Auth(object): elif args(1) == self.settings.cas_actions['proxyvalidate']: return self.cas_validate(version=2, proxy=True) elif args(1) == self.settings.cas_actions['logout']: - return self.logout(next=request.vars.service or DEFAULT) + return self.logout(next=request.vars.service or DEFAULT) else: raise HTTP(404) @@ -3738,6 +4042,44 @@ class Auth(object): for callback in onaccept: callback(form) + def jwt(self): + """ + To user JWT authentication, instantiate auth with + + auth = Auth(db, jwt = {'secret_key':'secret'}) + + where 'secret' is your own secret string. Then decorate functions that requires login + but will accept the JWT token as credential as: + + @auth.allows_jwt() + @auth.requires_login() + def myapi(): return 'hello %s' % auth.user.email + + (notice the jwt is allowed but not required. if user is logged in, myapi is accessible) + Now API users can obtain a token with + + http://.../app/default/user/jwt?username=...&password=.... + + They can refresh an existing token with + + http://.../app/default/user/jwt?token=... + + And they can authenticate themselves when calling the myapi by injecting a header + + Authorization: Bearer + + For additional arguments that can be passed to + + auth = Auth(db, jwt = {...}) + + please see class AuthJWT.__init__(...) arguments. + """ + if not self.jwt_handler: + raise HTTP(400, "Not authorized") + else: + current.response.headers['content-type'] = 'application/json' + raise HTTP(200, self.jwt_handler.jwt_token_manager()) + def is_impersonating(self): return self.is_logged_in() and 'impersonator' in current.session.auth @@ -3836,6 +4178,12 @@ class Auth(object): raise HTTP(403, 'ACCESS DENIED') return self.messages.access_denied + def allows_jwt(self, otherwise=None): + if not self.jwt_handler: + raise HTTP(400, "Not authorized") + else: + return self.jwt_handler.allows_jwt() + def requires(self, condition, requires_login=True, otherwise=None): """ Decorator that prevents access to action if not logged in