def _validate_user_class(cls, user_class): """ Validates the supplied user_class to make sure that it has the class methods necessary to function correctly. Requirements: - ``lookup`` method. Accepts a string parameter, returns instance - ``identify`` method. Accepts an identity parameter, returns instance """ PraetorianError.require_condition( getattr(user_class, 'lookup', None) is not None, textwrap.dedent(""" The user_class must have a lookup class method: user_class.lookup(<str>) -> <user instance> """), ) PraetorianError.require_condition( getattr(user_class, 'identify', None) is not None, textwrap.dedent(""" The user_class must have an identify class method: user_class.identify(<identity>) -> <user instance> """), ) # TODO: Figure out how to check for an identity property return user_class
def wrapper(*args, **kwargs): PraetorianError.require_condition( current_rolenames().issubset(set(accepted_rolenames)), "This endpoint requires one of the following roles: {}", [', '.join(accepted_rolenames)], ) return method(*args, **kwargs)
def authenticate(self, username, password): """ Verifies that a password matches the stored password for that username. If verification passes, the matching user instance is returned """ PraetorianError.require_condition( self.user_class is not None, "Praetorian must be initialized before this method is available", ) user = self.user_class.lookup(username) AuthenticationError.require_condition( user is not None and self._verify_password( password, user.password, ), 'The username and/or password are incorrect', ) """ If we are set to PRAETORIAN_HASH_AUTOUPDATE then check our hash and if needed, update the user. The developer is responsible for using the returned user object and updating the data storage endpoint. Else, if we are set to PRAETORIAN_HASH_AUTOTEST then check out hash and return exception if our hash is using the wrong scheme, but don't modify the user. """ if self.hash_autoupdate: self.verify_and_update(user=user, password=password) elif self.hash_autotest: self.verify_and_update(user=user) return user
def encrypt_password(self, raw_password): """ Encrypts a plaintext password using the stored passlib password context """ PraetorianError.require_condition( self.pwd_ctx is not None, "Praetorian must be initialized before this method is available", ) return self.pwd_ctx.encrypt(raw_password, scheme=self.hash_scheme)
def _verify_password(self, raw_password, hashed_password): """ Verifies that a plaintext password matches the hashed version of that password using the stored passlib password context """ PraetorianError.require_condition( self.pwd_ctx is not None, "Praetorian must be initialized before this method is available", ) return self.pwd_ctx.verify(raw_password, hashed_password)
def pack_header_for_user(self, user): """ A method that may only be used for testing that packages a jwt token into a header dict for a given user """ PraetorianError.require_condition( self.is_testing, "Pack header may only be used for testing", ) token = self.encode_jwt_token(user) return {self.header_name: self.header_type + ' ' + token}
def current_guard(): """ Fetches the current instance of flask-praetorian that is attached to the current flask app """ guard = flask.current_app.extensions.get('praetorian', None) PraetorianError.require_condition( guard is not None, "No current guard found; Praetorian must be initialized first", ) return guard
def current_user_id(): """ This method returns the user id retrieved from jwt token data attached to the current flask app's context """ jwt_data = get_jwt_data_from_app_context() user_id = jwt_data.get('id') PraetorianError.require_condition( user_id is not None, "Could not fetch an id for the current user", ) return user_id
def authenticate(self, username, password): """ Verifies that a password matches the stored password for that username. If verification passes, the matching user instance is returned """ PraetorianError.require_condition( self.user_class is not None, "Praetorian must be initialized before this method is available", ) user = self.user_class.lookup(username) if user is None or not self.verify_password(password, user.password): return None return user
def current_token(token_id): """ This method returns a user instance for jwt token data attached to the current flask app's context """ #token_id = current_token_id() guard = current_guard() token = guard.token_store_class.identify(token_id) PraetorianError.require_condition( token is not None, "Could not identify the current token from the current id", ) return token
def current_user(): """ This method returns a user instance for jwt token data attached to the current flask app's context """ user_id = current_user_id() guard = current_guard() user = guard.user_class.identify(user_id) PraetorianError.require_condition( user is not None, "Could not identify the current user from the current id", ) return user
def hash_password(self, raw_password): """ Hashes a plaintext password using the stored passlib password context """ PraetorianError.require_condition( self.pwd_ctx is not None, "Praetorian must be initialized before this method is available", ) """ `scheme` is now set with self.pwd_ctx.update(default=scheme) due to the depreciation in upcoming passlib 2.0. zillions of warnings suck. """ return self.pwd_ctx.hash(raw_password)
def get_jwt_data_from_app_context(): """ Fetches a dict of jwt token data from the top of the flask app's context """ ctx = flask._app_ctx_stack.top jwt_data = getattr(ctx, 'jwt_data', None) PraetorianError.require_condition( jwt_data is not None, """ No jwt_data found in app context. Make sure @auth_required decorator is specified *first* for route """, ) return jwt_data
def wrapper(*args, **kwargs): PraetorianError.require_condition( not current_guard().roles_disabled, "This feature is not available because roles are disabled", ) role_set = set([str(n) for n in required_rolenames]) _verify_and_add_jwt() try: MissingRoleError.require_condition( current_rolenames().issuperset(role_set), "This endpoint requires all the following roles: " "{}".format([", ".join(role_set)]), ) return method(*args, **kwargs) finally: remove_jwt_data_from_app_context()
def current_user(): """ This method returns a user instance for jwt token data attached to the current flask app's context """ jwt_data = get_jwt_data_from_app_context() user_id = jwt_data.get('id') PraetorianError.require_condition( user_id is not None, "Could not fetch an id for the current user", ) guard = current_guard() user = guard.user_class.identify(user_id) PraetorianError.require_condition( user is not None, "Could not identify the current user from the current id", ) return user
def get_user_from_registration_token(self, token): """ Gets a user based on the registration token that is supplied. Verifies that the token is a regisration token and that the user can be properly retrieved """ data = self.extract_jwt_token(token, access_type=AccessType.register) user_id = data.get("id") PraetorianError.require_condition( user_id is not None, "Could not fetch an id from the registration token", ) user = self.user_class.identify(user_id) PraetorianError.require_condition( user is not None, "Could not identify the user from the registration token", ) return user
def validate_reset_token(self, token): """ Validates a password reset request based on the reset token that is supplied. Verifies that the token is a reset token and that the user can be properly retrieved """ data = self.extract_jwt_token(token, access_type=AccessType.reset) user_id = data.get('id') PraetorianError.require_condition( user_id is not None, "Could not fetch an id from the reset token", ) user = self.user_class.identify(user_id) PraetorianError.require_condition( user is not None, "Could not identify the user from the reset token", ) return user
def authenticate(self, username, password): """ Verifies that a password matches the stored password for that username. If verification passes, the matching user instance is returned """ PraetorianError.require_condition( self.user_class is not None, "Praetorian must be initialized before this method is available", ) user = self.user_class.lookup(username) MissingUserError.require_condition( user is not None, 'Could not find the requested user', ) AuthenticationError.require_condition( self._verify_password(password, user.password), 'The password is incorrect', ) return user
def extract_jwt_token(self, token): """ Extracts a data dictionary from a jwt token """ # Note: we disable exp verification because we will do it ourselves with PraetorianError.handle_errors('failed to decode JWT token'): data = jwt.decode( token, self.encode_key, algorithms=self.allowed_algorithms, options={'verify_exp': False}, ) self.validate_jwt_data(data, access_type=AccessType.access) return data
def refresh_jwt_token(self, token): """ Creates a new token for a user if and only if the old token's access permission is expired but its refresh permission is not yet expired. The new token's refresh expiration moment is the same as the old token's, but the new token's access expiration is refreshed """ moment = datetime.datetime.utcnow() # Note: we disable exp verification because we do custom checks here data = jwt.decode( token, self.encode_key, algorithms=self.allowed_algorithms, options={'verify_exp': False}, ) self.validate_jwt_data(data, access_type=AccessType.refresh) user = self.user_class.identify(data['id']) PraetorianError.require_condition( user is not None, 'Could not find an active user for the token', ) payload_parts = dict( iat=moment, exp=moment + self.access_lifespan, rf_exp=data['rf_exp'], jti=data['jti'], id=data['id'], rls=','.join(user.rolenames), ) return jwt.encode( payload_parts, self.encode_key, self.encode_algorithm, ).decode('utf-8')
def send_token_email(self, email, user=None, template=None, action_sender=None, action_uri=None, subject=None, override_access_lifespan=None, custom_token=None, sender='no-reply@praetorian'): """ Sends an email to a user, containing a time expiring token usable for several actions. This requires your application is initialized with a `mail` extension, which supports Flask-Mail's `Message()` object and a `send()` method. Returns a dict containing the information sent, along with the `result` from mail send. :param: email: The email address to use (username, id, email, etc) :param: user: The user object to tie claim to (username, id, email, etc) :param: template: HTML Template for confirmation email. If not provided, a stock entry is used :param: action_sender: The sender that should be attached to the confirmation email. :param: action_uri: The uri that should be visited to complete the token action. :param: subject: The email subject. :param: override_access_lifespan: Overrides the JWT_ACCESS_LIFESPAN to set an access lifespan for the registration token. """ notification = { 'result': None, 'message': None, 'user': str(user), 'email': email, 'token': custom_token, 'subject': subject, 'confirmation_uri': action_uri, # backwards compatibility 'action_uri': action_uri, } PraetorianError.require_condition( action_sender, "A sender is required to send confirmation email", ) PraetorianError.require_condition( custom_token, "A custom_token is required to send notification email", ) if template is None: with open(self.confirmation_template) as fh: template = fh.read() with PraetorianError.handle_errors('fail sending email'): flask.current_app.logger.debug( "NOTIFICATION: {}".format(notification)) jinja_tmpl = jinja2.Template(template) notification['message'] = jinja_tmpl.render(notification).strip() msg = Message(html=notification['message'], sender=action_sender, subject=notification['subject'], recipients=[notification['email']]) flask.current_app.logger.debug("Sending email to {}".format(email)) notification['result'] = flask.current_app.extensions['mail'].send( msg) return notification
def init_app(self, app, user_class, is_blacklisted=None): """ Initializes the Praetorian extension :param: app: The flask app to bind this extension to :param: user_class: The class used to interact with user data :param: is_blacklisted: A method that may optionally be used to check the token against a blacklist when access or refresh is requested Should take the jti for the token to check as a single argument. Returns True if the jti is blacklisted, False otherwise. By default, always returns False. """ PraetorianError.require_condition( app.config.get('SECRET_KEY') is not None, "There must be a SECRET_KEY app config setting set", ) possible_schemes = [ 'argon2', 'bcrypt', 'pbkdf2_sha512', ] self.pwd_ctx = CryptContext( default='pbkdf2_sha512', schemes=possible_schemes + ['plaintext'], deprecated=[], ) self.hash_scheme = app.config.get('PRAETORIAN_HASH_SCHEME') valid_schemes = self.pwd_ctx.schemes() PraetorianError.require_condition( self.hash_scheme in valid_schemes or self.hash_scheme is None, "If {} is set, it must be one of the following schemes: {}", 'PRAETORIAN_HASH_SCHEME', valid_schemes, ) self.user_class = self._validate_user_class(user_class) self.is_blacklisted = is_blacklisted or (lambda t: False) self.encode_key = app.config['SECRET_KEY'] self.allowed_algorithms = app.config.get( 'JWT_ALLOWED_ALGORITHMS', DEFAULT_JWT_ALLOWED_ALGORITHMS, ) self.encode_algorithm = app.config.get( 'JWT_ALGORITHM', DEFAULT_JWT_ALGORITHM, ) self.access_lifespan = pendulum.Duration(**app.config.get( 'JWT_ACCESS_LIFESPAN', DEFAULT_JWT_ACCESS_LIFESPAN, )) self.refresh_lifespan = pendulum.Duration(**app.config.get( 'JWT_REFRESH_LIFESPAN', DEFAULT_JWT_REFRESH_LIFESPAN, )) self.header_name = app.config.get( 'JWT_HEADER_NAME', DEFAULT_JWT_HEADER_NAME, ) self.header_type = app.config.get( 'JWT_HEADER_TYPE', DEFAULT_JWT_HEADER_TYPE, ) self.user_class_validation_method = app.config.get( 'USER_CLASS_VALIDATION_METHOD', DEFAULT_USER_CLASS_VALIDATION_METHOD, ) if not app.config.get('DISABLE_PRAETORIAN_ERROR_HANDLER'): app.register_error_handler( PraetorianError, PraetorianError.build_error_handler(), ) self.is_testing = app.config.get('TESTING', False) if not hasattr(app, 'extensions'): app.extensions = {} app.extensions['praetorian'] = self
def __init__(self, *args): PraetorianError.require_condition(len(args) > 0, "BOOM")
def send_token_email( self, email, user=None, template=None, action_sender=None, action_uri=None, subject=None, override_access_lifespan=None, custom_token=None, ): """ Sends an email to a user, containing a time expiring token usable for several actions. This requires your application is initialized with a `mail` extension, which supports Flask-Mail's `Message()` object and a `send()` method. Returns a dict containing the information sent, along with the `result` from mail send. :param: email: The email address to use (username, id, email, etc) :param: user: The user object to tie claim to (username, id, email, etc) :param: template: HTML Template for confirmation email. If not provided, a stock entry is used :param: action_sender: The sender that should be attached to the confirmation email. :param: action_uri: The uri that should be visited to complete the token action. :param: subject: The email subject. :param: override_access_lifespan: Overrides the JWT_ACCESS_LIFESPAN to set an access lifespan for the registration token. :param: custom_token: The token to be carried as the email's payload """ notification = { "result": None, "message": None, "user": str(user), "email": email, "token": custom_token, "subject": subject, "confirmation_uri": action_uri, # backwards compatibility "action_uri": action_uri, } PraetorianError.require_condition( "mail" in flask.current_app.extensions, "Your app must have a mail extension enabled to register by email", ) PraetorianError.require_condition( action_sender, "A sender is required to send confirmation email", ) PraetorianError.require_condition( custom_token, "A custom_token is required to send notification email", ) if template is None: with open(self.confirmation_template) as fh: template = fh.read() with PraetorianError.handle_errors("fail sending email"): jinja_tmpl = jinja2.Template(template) notification["message"] = jinja_tmpl.render(notification).strip() msg = Message( html=notification["message"], sender=action_sender, subject=notification["subject"], recipients=[notification["email"]], ) flask.current_app.logger.debug("Sending email to {}".format(email)) notification["result"] = flask.current_app.extensions["mail"].send( msg ) return notification
def init_app( self, app=None, user_class=None, token_store_class=None, is_blacklisted=None, encode_jwt_token_hook=None, refresh_jwt_token_hook=None, ): """ Initializes the Praetorian extension :param: app: The flask app to bind this extension to :param: user_class: The class used to interact with user data :param: token_store_class: The class used to interact with token store data :param: is_blacklisted: A method that may optionally be used to check the token against a blacklist when access or refresh is requested should take the jti for the token to check as a single argument. Returns True if the jti is blacklisted, False otherwise. By default, always returns False. :param encode_jwt_token_hook: A method that may optionally be called right before an encoded jwt is generated. Should take payload_parts which contains the ingredients for the jwt. :param refresh_jwt_token_hook: A method that may optionally be called right before an encoded jwt is refreshed. Should take payload_parts which contains the ingredients for the jwt. """ PraetorianError.require_condition( app.config.get('SECRET_KEY') is not None, "There must be a SECRET_KEY app config setting set", ) self.roles_disabled = app.config.get( 'PRAETORIAN_ROLES_DISABLED', DEFAULT_ROLES_DISABLED, ) self.hash_autoupdate = app.config.get( 'PRAETORIAN_HASH_AUTOUPDATE', DEFAULT_HASH_AUTOUPDATE, ) self.hash_autotest = app.config.get( 'PRAETORIAN_HASH_AUTOTEST', DEFAULT_HASH_AUTOTEST, ) self.pwd_ctx = CryptContext( schemes=app.config.get( 'PRAETORIAN_HASH_ALLOWED_SCHEMES', DEFAULT_HASH_ALLOWED_SCHEMES, ), default=app.config.get( 'PRAETORIAN_HASH_SCHEME', DEFAULT_HASH_SCHEME, ), deprecated=app.config.get( 'PRAETORIAN_HASH_DEPRECATED_SCHEMES', DEFAULT_HASH_DEPRECATED_SCHEMES, ), ) valid_schemes = self.pwd_ctx.schemes() PraetorianError.require_condition( self.hash_scheme in valid_schemes or self.hash_scheme is None, "If {} is set, it must be one of the following schemes: {}".format( 'PRAETORIAN_HASH_SCHEME', valid_schemes, ), ) self.user_class = self._validate_user_class(user_class) #self.token_class = self._validate_token_class(token_class) self.token_store_class = token_store_class self.is_blacklisted = is_blacklisted or (lambda t: False) self.encode_jwt_token_hook = encode_jwt_token_hook self.refresh_jwt_token_hook = refresh_jwt_token_hook self.encode_key = app.config['SECRET_KEY'] self.allowed_algorithms = app.config.get( 'JWT_ALLOWED_ALGORITHMS', DEFAULT_JWT_ALLOWED_ALGORITHMS, ) self.encode_algorithm = app.config.get( 'JWT_ALGORITHM', DEFAULT_JWT_ALGORITHM, ) self.access_lifespan = app.config.get( 'JWT_ACCESS_LIFESPAN', DEFAULT_JWT_ACCESS_LIFESPAN, ) self.refresh_lifespan = app.config.get( 'JWT_REFRESH_LIFESPAN', DEFAULT_JWT_REFRESH_LIFESPAN, ) self.reset_lifespan = app.config.get( 'JWT_RESET_LIFESPAN', DEFAULT_JWT_RESET_LIFESPAN, ) self.jwt_places = app.config.get( 'JWT_PLACES', DEFAULT_JWT_PLACES, ) self.cookie_name = app.config.get( 'JWT_COOKIE_NAME', DEFAULT_JWT_COOKIE_NAME, ) self.header_name = app.config.get( 'JWT_HEADER_NAME', DEFAULT_JWT_HEADER_NAME, ) self.header_type = app.config.get( 'JWT_HEADER_TYPE', DEFAULT_JWT_HEADER_TYPE, ) self.user_class_validation_method = app.config.get( 'USER_CLASS_VALIDATION_METHOD', DEFAULT_USER_CLASS_VALIDATION_METHOD, ) self.confirmation_template = app.config.get( 'PRAETORIAN_CONFIRMATION_TEMPLATE', DEFAULT_CONFIRMATION_TEMPLATE, ) self.confirmation_uri = app.config.get('PRAETORIAN_CONFIRMATION_URI', ) self.confirmation_sender = app.config.get( 'PRAETORIAN_CONFIRMATION_SENDER', ) self.confirmation_subject = app.config.get( 'PRAETORIAN_CONFIRMATION_SUBJECT', DEFAULT_CONFIRMATION_SUBJECT, ) self.reset_template = app.config.get( 'PRAETORIAN_RESET_TEMPLATE', DEFAULT_RESET_TEMPLATE, ) self.reset_uri = app.config.get('PRAETORIAN_RESET_URI', ) self.reset_sender = app.config.get('PRAETORIAN_RESET_SENDER', ) self.reset_subject = app.config.get( 'PRAETORIAN_RESET_SUBJECT', DEFAULT_RESET_SUBJECT, ) if isinstance(self.access_lifespan, dict): self.access_lifespan = pendulum.duration(**self.access_lifespan) elif isinstance(self.access_lifespan, str): self.access_lifespan = duration_from_string(self.access_lifespan) ConfigurationError.require_condition( isinstance(self.access_lifespan, datetime.timedelta), "access lifespan was not configured", ) if isinstance(self.refresh_lifespan, dict): self.refresh_lifespan = pendulum.duration(**self.refresh_lifespan) if isinstance(self.refresh_lifespan, str): self.refresh_lifespan = duration_from_string(self.refresh_lifespan) ConfigurationError.require_condition( isinstance(self.refresh_lifespan, datetime.timedelta), "refresh lifespan was not configured", ) if not app.config.get('DISABLE_PRAETORIAN_ERROR_HANDLER'): app.register_error_handler( PraetorianError, PraetorianError.build_error_handler(), ) self.is_testing = app.config.get('TESTING', False) if not hasattr(app, 'extensions'): app.extensions = {} app.extensions['praetorian'] = self return app
def _validate_token_class(self, token_store_class): """ Validates the supplied user_class to make sure that it has the class methods and attributes necessary to function correctly. After validating class methods, will attempt to instantiate a dummy instance of the user class to test for the requisite attributes Requirements: - ``lookup`` method. Accepts a string parameter, returns instance - ``identify`` method. Accepts an identity parameter, returns instance - ``identity`` attribute. Provides unique id for the instance - ``rolenames`` attribute. Provides list of roles attached to instance - ``password`` attribute. Provides hashed password for instance # TODO remove password """ PraetorianError.require_condition( getattr(token_store_class, 'lookup', None) is not None, textwrap.dedent(""" The user_class must have a lookup class method: user_class.lookup(<str>) -> <user instance> """), ) PraetorianError.require_condition( getattr(token_store_class, 'identify', None) is not None, textwrap.dedent(""" The user_class must have an identify class method: user_class.identify(<identity>) -> <user instance> """), ) dummy_token_store = None try: dummy_token_store = token_store_class() except Exception: flask.current_app.logger.debug( "Skipping instance validation because " "token cannot be instantiated without arguments") if dummy_token_store: PraetorianError.require_condition( hasattr(dummy_token_store, "identity"), textwrap.dedent(""" Instances of token_class must have an identity attribute: token_instance.identity -> <unique id for instance> """), ) PraetorianError.require_condition( self.roles_disabled or hasattr(dummy_token_store, "rolenames"), textwrap.dedent(""" Instances of token_class must have a rolenames attribute: token_instance.rolenames -> [<role1>, <role2>, ...] """), ) # PraetorianError.require_condition( # hasattr(dummy_token, "password"), # textwrap.dedent(""" # Instances of user_class must have a password attribute: # user_instance.rolenames -> <hashed password> # """), # ) return dummy_token_store
def init_app(app): api.init_app(app) PraetorianError.register_error_handler_with_flask_restplus(api)
def send_registration_email(self, email, user=None, template=None, confirmation_sender=None, confirmation_uri=None, subject=None, override_access_lifespan=None): """ Sends a registration email to a new user, containing a time expiring token usable for validation. This requires your application is initiliazed with a `mail` extension, which supports Flask-Mail's `Message()` object and a `send()` method. Returns a dict containing the information sent, along with the `result` from mail send. :param: user: The user object to tie claim to (username, id, email, etc) :param: template: HTML Template for confirmation email. If not provided, a stock entry is used :param: confirmation_sender: The sender that shoudl be attached to the confirmation email. Overrides the PRAETORIAN_CONFIRMRATION_SENDER config setting :param: confirmation_uri: The uri that should be visited to complete email registration. Should usually be a uri to a frontend or external service that calls a 'finalize' method in the api to complete registration. Will override the PRAETORIAN_CONFIRMATION_URI config setting :param: subject: The registration email subject. Will override the PRAETORIAN_CONFIRMATION_SUBJECT config setting. :param: override_access_lifespan: Overrides the JWT_ACCESS_LIFESPAN to set an access lifespan for the registration token. """ if subject is None: subject = self.confirmation_subject if confirmation_uri is None: confirmation_uri = self.confirmation_uri notification = { 'result': None, 'message': None, 'user': str(user), 'email': email, 'token': None, 'subject': subject, 'confirmation_uri': confirmation_uri, } sender = confirmation_sender or self.confirmation_sender PraetorianError.require_condition( sender, "A confirmation sender is required to send confirmation email", ) if template is None: with open(self.confirmation_template) as fh: template = fh.read() with PraetorianError.handle_errors('fail sending confirmation email'): app = flask.current_app app.logger.debug("NOTIFICATION: {}".format(notification)) app.logger.debug( "Generating registration token with lifespan: {}".format( override_access_lifespan)) notification['token'] = self.encode_jwt_token( user, override_access_lifespan=override_access_lifespan, bypass_user_check=True, is_registration_token=True, ) jinja_tmpl = jinja2.Template(template) notification['message'] = jinja_tmpl.render(notification).strip() msg = Message(html=notification['message'], sender=sender, subject=notification['subject'], recipients=[notification['email']]) app.logger.debug("Sending verification email to {}".format(email)) notification['result'] = app.extensions['mail'].send(msg) return notification