""" Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to you 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 logging import copy from cryptography.fernet import Fernet from abc import abstractmethod from yosai.core import ( AdditionalAuthenticationRequired, AuthenticationException, DefaultAuthenticator, DelegatingSubject, EventLogger, NativeSessionManager, SessionKey, SubjectContext, SubjectStore, InvalidSessionException, ModularRealmAuthorizer, RememberMeSettings, event_bus, mgt_abcs, ) logger = logging.getLogger(__name__) class AbstractRememberMeManager(mgt_abcs.RememberMeManager): """ Abstract implementation of the ``RememberMeManager`` interface that handles serialization and encryption of the remembered user identity. The remembered identity storage location and details are left to subclasses. Default encryption key ----------------------- This implementation uses the Fernet API from PyCA's cryptography for symmetric encryption. As per the documentation, Fernet uses AES in CBC mode with a 128-bit key for encryption and uses PKCS7 padding: https://cryptography.io/en/stable/fernet/ It also uses a default, generated symmetric key to both encrypt and decrypt data. As AES is a symmetric cipher, the same key is used to both encrypt and decrypt data, BUT NOTE: Because Yosai is an open-source project, if anyone knew that you were using Yosai's default key, they could download/view the source, and with enough effort, reconstruct the key and decode encrypted data at will. Of course, this key is only really used to encrypt the remembered ``IdentifierCollection``, which is typically a user id or username. So if you do not consider that sensitive information, and you think the default key still makes things 'sufficiently difficult', then you can ignore this issue. However, if you do feel this constitutes sensitive information, it is recommended that you provide your own key and set it via the cipher_key property attribute to a key known only to your application, guaranteeing that no third party can decrypt your data. You can generate your own key by importing fernet and calling its generate_key method: >>> from cryptography.fernet import Fernet >>> key = Fernet.generate_key() your key will be a byte string that looks like this: b'cghiiLzTI6CUFCO5Hhh-5RVKzHTQFZM2QSZxxgaC6Wo=' copy and paste YOUR newly generated byte string, excluding the bytestring notation, into its respective place in /conf/yosai.core.settings.json following this format: default_cipher_key = "cghiiLzTI6CUFCO5Hhh-5RVKzHTQFZM2QSZxxgaC6Wo=" """ def __init__(self, settings): default_cipher_key = RememberMeSettings(settings).default_cipher_key # new to yosai.core. self.serialization_manager = None # it will be injected # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!i!!!!!!!! # !!! # # # 888 # 888 # 888 # .d88888 8888b. 88888b. .d88b. .d88b. 888 888b # d88" 888 "88 b888 "88b d88P"88b d8P Y8 b888P"`` # 888 888 .d88888 8888 888 888 888 8888888 8888 # Y88b 888 888 88 8888 888 Y88b 888 Y8b. 888 # "Y88888 "Y88888 8888 888 "Y888888 "Y8888 888 # 8888 # .Y8b # "Y88P" # # HEY YOU! # !!! Generate your own cipher key using the instructions above and # !!! update your yosai settings file to include it. The code below # !!! references this key. Yosai does not include a default key. # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! # as default, the encryption key == decryption key: self.encryption_cipher_key = default_cipher_key self.decryption_cipher_key = default_cipher_key @abstractmethod def forget_identity(self, subject): """ Forgets (removes) any remembered identity data for the specified Subject instance. :param subject: the subject instance for which identity data should be forgotten from the underlying persistence mechanism """ pass def on_successful_login(self, subject, authc_token, account_id): """ Reacts to the successful login attempt by first always forgetting any previously stored identity. Then if the authc_token is a ``RememberMe`` type of token, the associated identity will be remembered for later retrieval during a new user session. :param subject: the subject whose identifying attributes are being remembered :param authc_token: the token that resulted in a successful authentication attempt :param account_id: id of authenticated account """ # always clear any previous identity: self.forget_identity(subject) # now save the new identity: if authc_token.is_remember_me: self.remember_identity(subject, authc_token, account_id) else: msg = ("AuthenticationToken did not indicate that RememberMe is " "requested. RememberMe functionality will not be executed " "for corresponding account.") logger.debug(msg) def remember_identity(self, subject, authc_token, account_id): """ Yosai consolidates rememberIdentity, an overloaded method in java, to a method that will use an identifier-else-account logic. Remembers a subject-unique identity for retrieval later. This implementation first resolves the exact identifying attributes to remember. It then remembers these identifying attributes by calling remember_identity(Subject, IdentifierCollection) :param subject: the subject for which the identifying attributes are being remembered :param authc_token: ignored in the AbstractRememberMeManager :param account_id: the account id of authenticated account """ try: identifiers = self.get_identity_to_remember(subject, account_id) except AttributeError: msg = "Neither account_id nor identifier arguments passed" raise AttributeError(msg) encrypted = self.convert_identifiers_to_bytes(identifiers) self.remember_encrypted_identity(subject, encrypted) def get_identity_to_remember(self, subject, account_id): """ Returns the account's identifier and ignores the subject argument :param subject: the subject whose identifiers are remembered :param account: the account resulting from the successful authentication attempt :returns: the IdentifierCollection to remember """ # This is a placeholder. A more meaningful logic is implemented by subclasses return account_id def convert_identifiers_to_bytes(self, identifiers): """ Encryption requires a binary type as input, so this method converts the identifier collection object to one. :type identifiers: a serializable IdentifierCollection object :returns: a bytestring """ # serializes to bytes by default: return self.encrypt(self.serialization_manager.serialize(identifiers)) @abstractmethod def remember_encrypted_identity(subject, encrypted): """ Persists the identity bytes to a persistent store :param subject: the Subject for whom the identity is being serialized :param serialized: the serialized bytes to be persisted. """ pass def get_remembered_identifiers(self, subject_context): identifiers = None try: encrypted = self.get_remembered_encrypted_identity(subject_context) if encrypted: identifiers = self.convert_bytes_to_identifiers(encrypted, subject_context) except Exception as ex: identifiers = \ self.on_remembered_identifiers_failure(ex, subject_context) return identifiers @abstractmethod def get_remembered_encrypted_identity(subject_context): """ Based on the given subject context data, retrieves the previously persisted serialized identity, or None if there is no available data. :param subject_context: the contextual data, that is being used to construct a Subject instance. :returns: the previously persisted serialized identity, or None if no such data can be acquired for the Subject """ pass def convert_bytes_to_identifiers(self, encrypted, subject_context): """ If a cipher_service is available, it will be used to first decrypt the serialized message. Then, the bytes are deserialized and returned. :param serialized: the bytes to decrypt and then deserialize :param subject_context: the contextual data, that is being used to construct a Subject instance :returns: the de-serialized identifier """ # unlike Shiro, Yosai assumes that the message is encrypted: decrypted = self.decrypt(encrypted) return self.serialization_manager.deserialize(decrypted) def on_remembered_identifiers_failure(self, exc, subject_context): """ Called when an exception is thrown while trying to retrieve identifier. The default implementation logs a debug message and forgets ('unremembers') the problem identity by calling forget_identity(subject_context) and then immediately re-raises the exception to allow the calling component to react accordingly. This method implementation never returns an object - it always rethrows, but can be overridden by subclasses for custom handling behavior. This most commonly would be called when an encryption key is updated and old identifier are retrieved that have been encrypted with the previous key. :param exc: the exception that was thrown :param subject_context: the contextual data that is being used to construct a Subject instance :raises: the original Exception passed is propagated in all cases """ msg = ("There was a failure while trying to retrieve remembered " "identifier. This could be due to a configuration problem or " "corrupted identifier. This could also be due to a recently " "changed encryption key. The remembered identity will be " "forgotten and not used for this request. ", exc) logger.debug(msg) self.forget_identity(subject_context) # propagate - security manager implementation will handle and warn # appropriately: raise exc def encrypt(self, serialized): """ Encrypts the serialized message using Fernet :param serialized: the serialized object to encrypt :type serialized: bytes :returns: an encrypted bytes returned by Fernet """ fernet = Fernet(self.encryption_cipher_key) return fernet.encrypt(serialized) def decrypt(self, encrypted): """ decrypts the encrypted message using Fernet :param encrypted: the encrypted message :returns: the decrypted, serialized identifier collection """ fernet = Fernet(self.decryption_cipher_key) return fernet.decrypt(encrypted) def on_failed_login(self, subject, authc_token, ae): """ Reacts to a failed login by immediately forgetting any previously remembered identity. This is an additional security feature to prevent any remenant identity data from being retained in case the authentication attempt is not being executed by the expected user. :param subject: the subject which executed the failed login attempt :param authc_token: the authentication token resulting in a failed login attempt - ignored by this implementation :param ae: the exception thrown as a result of the failed login attempt - ignored by this implementation """ self.forget_identity(subject) def on_logout(self, subject): """ Reacts to a subject logging out of the application and immediately forgets any previously stored identity and returns. :param subject: the subject logging out """ self.forget_identity(subject) # also known as ApplicationSecurityManager in Shiro 2.0 alpha: class NativeSecurityManager(mgt_abcs.SecurityManager): def __init__(self, yosai, settings, realms=None, cache_handler=None, authenticator=None, authorizer=ModularRealmAuthorizer(), serialization_manager=None, session_manager=None, remember_me_manager=None, subject_store=SubjectStore()): self.yosai = yosai self.subject_store = subject_store self.realms = realms self.remember_me_manager = remember_me_manager if not session_manager: session_manager = NativeSessionManager(settings) self.session_manager = session_manager self.authorizer = authorizer if not authenticator: authenticator = DefaultAuthenticator(settings) self.authenticator = authenticator if serialization_manager and self.remember_me_manager: self.remember_me_manager.serialization_manager = serialization_manager self.event_logger = EventLogger(event_bus) self.apply_event_bus(event_bus) self.apply_cache_handler(cache_handler) self.apply_realms() def apply_cache_handler(self, cache_handler): for realm in self.realms: if hasattr(realm, 'cache_handler'): # implies cache support realm.cache_handler = cache_handler if hasattr(self.session_manager, 'apply_cache_handler'): self.session_manager.apply_cache_handler(cache_handler) def apply_event_bus(self, eventbus): self.authenticator.event_bus = eventbus self.authorizer.event_bus = eventbus self.session_manager.apply_event_bus(eventbus) def apply_realms(self): """ :realm_s: an immutable collection of one or more realms :type realm_s: tuple """ self.authenticator.init_realms(self.realms) self.authorizer.init_realms(self.realms) def is_permitted(self, identifiers, permission_s): """ :type identifiers: SimpleIdentifierCollection :param permission_s: a collection of 1..N permissions :type permission_s: List of Permission object(s) or String(s) :returns: a List of tuple(s), containing the Permission and a Boolean indicating whether the permission is granted """ return self.authorizer.is_permitted(identifiers, permission_s) def is_permitted_collective(self, identifiers, permission_s, logical_operator): """ :type identifiers: SimpleIdentifierCollection :param permission_s: a collection of 1..N permissions :type permission_s: List of Permission object(s) or String(s) :param logical_operator: indicates whether all or at least one permission check is true (any) :type: any OR all (from python standard library) :returns: a Boolean """ return self.authorizer.is_permitted_collective(identifiers, permission_s, logical_operator) def check_permission(self, identifiers, permission_s, logical_operator): """ :type identifiers: SimpleIdentifierCollection :param permission_s: a collection of 1..N permissions :type permission_s: List of Permission objects or Strings :param logical_operator: indicates whether all or at least one permission check is true (any) :type: any OR all (from python standard library) :returns: a List of Booleans corresponding to the permission elements """ return self.authorizer.check_permission(identifiers, permission_s, logical_operator) def has_role(self, identifiers, role_s): """ :type identifiers: SimpleIdentifierCollection :param role_s: 1..N role identifiers (strings) :type role_s: Set of Strings :returns: a set of tuple(s), containing the role and a Boolean indicating whether the user is a member of the Role """ return self.authorizer.has_role(identifiers, role_s) def has_role_collective(self, identifiers, role_s, logical_operator): """ :type identifiers: SimpleIdentifierCollection :param logical_operator: indicates whether all or at least one permission check is true (any) :type: any OR all (from python standard library) :param role_s: 1..N role identifier :type role_s: a Set of Strings :returns: a Boolean """ return self.authorizer.has_role_collective(identifiers, role_s, logical_operator) def check_role(self, identifiers, role_s, logical_operator): """ :type identifiers: SimpleIdentifierCollection :param role_s: 1..N role identifier :type role_s: a Set of Strings :param logical_operator: indicates whether all or at least one permission check is true (any) :type: any OR all (from python standard library) :raises UnauthorizedException: if Subject not assigned to all roles """ return self.authorizer.check_role(identifiers, role_s, logical_operator) """ * ===================================================================== * * SessionManager Methods * * ===================================================================== * """ def start(self, session_context): return self.session_manager.start(session_context) def get_session(self, session_key): return self.session_manager.get_session(session_key) """ * ===================================================================== * * SecurityManager Methods * * ===================================================================== * """ # existing_subject is used by WebSecurityManager: def create_subject_context(self, existing_subject): if not hasattr(self, 'yosai'): msg = "SecurityManager has no Yosai attribute set." raise AttributeError(msg) return SubjectContext(self.yosai, self) def create_subject(self, authc_token=None, account_id=None, existing_subject=None, subject_context=None): """ Creates a ``Subject`` instance for the user represented by the given method arguments. It is an overloaded method, due to porting java to python, and is consequently highly likely to be refactored. It gets called in one of two ways: 1) when creating an anonymous subject, passing create_subject a subject_context argument 2) following a after successful login, passing all but the context argument This implementation functions as follows: - Ensures that the ``SubjectContext`` exists and is as populated as it can be, using heuristics to acquire data that may not have already been available to it (such as a referenced session or remembered identifiers). - Calls subject_context.do_create_subject to perform the Subject instance creation - Calls subject.save to ensure the constructed Subject's state is accessible for future requests/invocations if necessary - Returns the constructed Subject instance :type authc_token: subject_abcs.AuthenticationToken :param account_id: the identifiers of a newly authenticated user :type account: SimpleIdentifierCollection :param existing_subject: the existing Subject instance that initiated the authentication attempt :type subject: subject_abcs.Subject :type subject_context: subject_abcs.SubjectContext :returns: the Subject instance that represents the context and session data for the newly authenticated subject """ if subject_context is None: # this that means a successful login just happened # passing existing_subject is new to yosai: context = self.create_subject_context(existing_subject) context.authenticated = True context.authentication_token = authc_token context.account_id = account_id if (existing_subject): context.subject = existing_subject else: context = copy.copy(subject_context) # if this necessary? TBD. context = self.ensure_security_manager(context) context = self.resolve_session(context) context = self.resolve_identifiers(context) subject = self.do_create_subject(context) # DelegatingSubject # save this subject for future reference if necessary: # (this is needed here in case remember_me identifiers were resolved # and they need to be stored in the session, so we don't constantly # re-hydrate the remember_me identifier_collection on every operation). self.save(subject) return subject def update_subject_identity(self, account_id, subject): subject.identifiers = account_id self.save(subject) return subject def remember_me_successful_login(self, authc_token, account_id, subject): rmm = self.remember_me_manager if (rmm is not None): try: rmm.on_successful_login(subject, authc_token, account_id) except Exception: msg = ("Delegate RememberMeManager instance of type [" + rmm.__class__.__name__ + "] threw an exception " + "during on_successful_login. RememberMe services " + "will not be performed for account_id [" + str(account_id) + "].") logger.warning(msg, exc_info=True) else: msg = ("This " + rmm.__class__.__name__ + " instance does not have a [RememberMeManager] instance " + "configured. RememberMe services will not be performed " + "for account_id [" + str(account_id) + "].") logger.info(msg) def remember_me_failed_login(self, authc_token, authc_exc, subject): rmm = self.remember_me_manager if (rmm is not None): try: rmm.on_failed_login(subject, authc_token, authc_exc) except Exception: msg = ("Delegate RememberMeManager instance of type " "[" + rmm.__class__.__name__ + "] threw an exception " "during on_failed_login for AuthenticationToken [" + str(authc_token) + "].") logger.warning(msg, exc_info=True) def remember_me_logout(self, subject): rmm = self.remember_me_manager if (rmm is not None): try: rmm.on_logout(subject) except Exception as ex: msg = ("Delegate RememberMeManager instance of type [" + rmm.__class__.__name__ + "] threw an exception during " "on_logout for subject with identifiers [{identifiers}]". format(identifiers=subject.identifiers if subject else None)) logger.warning(msg, exc_info=True) def login(self, subject, authc_token): """ Login authenticates a user using an AuthenticationToken. If authentication is successful AND the Authenticator has determined that authentication is complete for the account, login constructs a Subject instance representing the authenticated account's identity. Once a subject instance is constructed, it is bound to the application for subsequent access before being returned to the caller. If login successfully authenticates a token but the Authenticator has determined that subject's account isn't considered authenticated, the account is configured for multi-factor authentication. Sessionless environments must pass all authentication tokens to login at once. :param authc_token: the authenticationToken to process for the login attempt :type authc_token: authc_abcs.authenticationToken :returns: a Subject representing the authenticated user :raises AuthenticationException: if there is a problem authenticating the specified authc_token :raises AdditionalAuthenticationRequired: during multi-factor authentication when additional tokens are required """ try: # account_id is a SimpleIdentifierCollection account_id = self.authenticator.authenticate_account(subject.identifiers, authc_token) # implies multi-factor authc not complete: except AdditionalAuthenticationRequired as exc: # identity needs to be accessible for subsequent authentication: self.update_subject_identity(exc.account_id, subject) # no need to propagate account further: raise AdditionalAuthenticationRequired except AuthenticationException as authc_ex: try: self.on_failed_login(authc_token, authc_ex, subject) except Exception: msg = ("on_failed_login method raised an exception. Logging " "and propagating original AuthenticationException.") logger.info(msg, exc_info=True) raise logged_in = self.create_subject(authc_token=authc_token, account_id=account_id, existing_subject=subject) self.on_successful_login(authc_token, account_id, logged_in) return logged_in def on_successful_login(self, authc_token, account_id, subject): self.remember_me_successful_login(authc_token, account_id, subject) def on_failed_login(self, authc_token, authc_exc, subject): self.remember_me_failed_login(authc_token, authc_exc, subject) def before_logout(self, subject): self.remember_me_logout(subject) def do_create_subject(self, subject_context): """ By the time this method is invoked, all possible ``SubjectContext`` data (session, identifiers, et. al.) has been made accessible using all known heuristics. :returns: a Subject instance reflecting the data in the specified SubjectContext data map """ security_manager = subject_context.resolve_security_manager() session = subject_context.resolve_session() session_creation_enabled = subject_context.session_creation_enabled # passing the session arg is new to yosai, eliminating redunant # get_session calls: identifiers = subject_context.resolve_identifiers(session) remembered = getattr(subject_context, 'remembered', False) authenticated = subject_context.resolve_authenticated(session) host = subject_context.resolve_host(session) return DelegatingSubject(identifiers=identifiers, remembered=remembered, authenticated=authenticated, host=host, session=session, session_creation_enabled=session_creation_enabled, security_manager=security_manager) def save(self, subject): """ Saves the subject's state to a persistent location for future reference. This implementation merely delegates saving to the internal subject_store. """ self.subject_store.save(subject) def delete(self, subject): """ This method removes (or 'unbinds') the Subject's state from the application, typically called during logout. This implementation merely delegates deleting to the internal subject_store. :param subject: the subject for which state will be removed """ self.subject_store.delete(subject) def ensure_security_manager(self, subject_context): """ Determines whether there is a ``SecurityManager`` instance in the context, and if not, adds 'self' to the context. This ensures that do_create_subject will have access to a ``SecurityManager`` during Subject construction. :param subject_context: the subject context data that may contain a SecurityManager instance :returns: the SubjectContext """ if (subject_context.resolve_security_manager() is not None): msg = ("Subject Context resolved a security_manager " "instance, so not re-assigning. Returning.") logger.debug(msg) return subject_context msg = ("No security_manager found in context. Adding self " "reference.") logger.debug(msg) subject_context.security_manager = self return subject_context def resolve_session(self, subject_context): """ This method attempts to resolve any associated session based on the context and returns a context that represents this resolved Session to ensure it may be referenced, if needed, by the invoked do_create_subject that performs actual ``Subject`` construction. If there is a ``Session`` already in the context (because that is what the caller wants to use for Subject construction) or if no session is resolved, this method effectively does nothing, returning an unmodified context as it was received by the method. :param subject_context: the subject context data that may resolve a Session instance :returns: the context """ if (subject_context.resolve_session() is not None): msg = ("Context already contains a session. Returning.") logger.debug(msg) return subject_context try: # Context couldn't resolve it directly, let's see if we can # since we have direct access to the session manager: session = self.resolve_context_session(subject_context) # if session is None, given that subject_context.session # is None there is no harm done by setting it to None again subject_context.session = session except InvalidSessionException: msg = ("Resolved subject_subject_context context session is " "invalid. Ignoring and creating an anonymous " "(session-less) Subject instance.") logger.debug(msg, exc_info=True) return subject_context def resolve_context_session(self, subject_context): session_key = self.get_session_key(subject_context) if (session_key is not None): return self.get_session(session_key) return None def get_session_key(self, subject_context): session_id = subject_context.session_id if (session_id is not None): return SessionKey(session_id) return None # yosai.core.omits is_empty method def resolve_identifiers(self, subject_context): """ ensures that a subject_context has identifiers and if it doesn't will attempt to locate them using heuristics """ session = subject_context.session identifiers = subject_context.resolve_identifiers(session) if (not identifiers): msg = ("No identity (identifier_collection) found in the " "subject_context. Looking for a remembered identity.") logger.debug(msg) identifiers = self.get_remembered_identity(subject_context) if identifiers: msg = ("Found remembered IdentifierCollection. Adding to the " "context to be used for subject construction.") logger.debug(msg) subject_context.identifiers = identifiers subject_context.remembered = True else: msg = ("No remembered identity found. Returning original " "context.") logger.debug(msg) return subject_context def create_session_context(self, subject_context): session_context = {} if (not subject_context.is_empty): session_context.update(subject_context.__dict__) session_id = subject_context.session_id if (session_id): session_context['session_id'] = session_id host = subject_context.resolve_host(None) if (host): session_context['host'] = host return session_context def logout(self, subject): """ Logs out the specified Subject from the system. Note that most application developers should not call this method unless they have a good reason for doing so. The preferred way to logout a Subject is to call ``Subject.logout()``, not by calling ``SecurityManager.logout`` directly. However, framework developers might find calling this method directly useful in certain cases. :param subject the subject to log out: :type subject: subject_abcs.Subject """ if (subject is None): msg = "Subject argument cannot be None." raise ValueError(msg) self.before_logout(subject) identifiers = copy.copy(subject.identifiers) # copy is new to yosai if (identifiers): msg = ("Logging out subject with primary identifier {0}".format( identifiers.primary_identifier)) logger.debug(msg) try: # this removes two internal attributes from the session: self.delete(subject) except Exception: msg = "Unable to cleanly unbind Subject. Ignoring (logging out)." logger.debug(msg, exc_info=True) finally: try: self.stop_session(subject) except Exception: msg2 = ("Unable to cleanly stop Session for Subject. " "Ignoring (logging out).") logger.debug(msg2, exc_info=True) def stop_session(self, subject): session = subject.get_session(False) if (session): session.stop(subject.identifiers) def get_remembered_identity(self, subject_context): """ Using the specified subject context map intended to build a ``Subject`` instance, returns any previously remembered identifiers for the subject for automatic identity association (aka 'Remember Me'). """ rmm = self.remember_me_manager if rmm is not None: try: return rmm.get_remembered_identifiers(subject_context) except Exception as ex: msg = ("Delegate RememberMeManager instance of type [" + rmm.__class__.__name__ + "] raised an exception during " "get_remembered_identifiers().") logger.warning(msg, exc_info=True) return None