diff options
Diffstat (limited to 'src/leap/bitmask/crypto/srpauth.py')
| -rw-r--r-- | src/leap/bitmask/crypto/srpauth.py | 606 | 
1 files changed, 606 insertions, 0 deletions
| diff --git a/src/leap/bitmask/crypto/srpauth.py b/src/leap/bitmask/crypto/srpauth.py new file mode 100644 index 00000000..fc0533fc --- /dev/null +++ b/src/leap/bitmask/crypto/srpauth.py @@ -0,0 +1,606 @@ +# -*- coding: utf-8 -*- +# srpauth.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +import binascii +import logging + +import requests +import srp +import json + +#this error is raised from requests +from simplejson.decoder import JSONDecodeError +from functools import partial + +from PySide import QtCore +from twisted.internet import threads + +from leap.common.check import leap_assert +from leap.util.constants import REQUEST_TIMEOUT +from leap.util import request_helpers as reqhelper +from leap.common.events import signal as events_signal +from leap.common.events import events_pb2 as proto + +logger = logging.getLogger(__name__) + + +class SRPAuthenticationError(Exception): +    """ +    Exception raised for authentication errors +    """ +    pass + + +class SRPAuthConnectionError(SRPAuthenticationError): +    """ +    Exception raised when there's a connection error +    """ +    pass + + +class SRPAuthUnknownUser(SRPAuthenticationError): +    """ +    Exception raised when trying to authenticate an unknown user +    """ +    pass + + +class SRPAuthBadStatusCode(SRPAuthenticationError): +    """ +    Exception raised when we received an unknown bad status code +    """ +    pass + + +class SRPAuthNoSalt(SRPAuthenticationError): +    """ +    Exception raised when we don't receive the salt param at a +    specific point in the auth process +    """ +    pass + + +class SRPAuthNoB(SRPAuthenticationError): +    """ +    Exception raised when we don't receive the B param at a specific +    point in the auth process +    """ +    pass + + +class SRPAuthBadDataFromServer(SRPAuthenticationError): +    """ +    Generic exception when we receive bad data from the server. +    """ +    pass + + +class SRPAuthJSONDecodeError(SRPAuthenticationError): +    """ +    Exception raised when there's a problem decoding the JSON content +    parsed as received from th e server. +    """ +    pass + + +class SRPAuthBadPassword(SRPAuthenticationError): +    """ +    Exception raised when the user provided a bad password to auth. +    """ +    pass + + +class SRPAuthVerificationFailed(SRPAuthenticationError): +    """ +    Exception raised when we can't verify the SRP data received from +    the server. +    """ +    pass + + +class SRPAuthNoSessionId(SRPAuthenticationError): +    """ +    Exception raised when we don't receive a session id from the +    server. +    """ +    pass + + +class SRPAuth(QtCore.QObject): +    """ +    SRPAuth singleton +    """ + +    class __impl(QtCore.QObject): +        """ +        Implementation of the SRPAuth interface +        """ + +        LOGIN_KEY = "login" +        A_KEY = "A" +        CLIENT_AUTH_KEY = "client_auth" +        SESSION_ID_KEY = "_session_id" + +        def __init__(self, provider_config): +            """ +            Constructor for SRPAuth implementation + +            :param server: Server to which we will authenticate +            :type server: str +            """ +            QtCore.QObject.__init__(self) + +            leap_assert(provider_config, +                        "We need a provider config to authenticate") + +            self._provider_config = provider_config + +            # **************************************************** # +            # Dependency injection helpers, override this for more +            # granular testing +            self._fetcher = requests +            self._srp = srp +            self._hashfun = self._srp.SHA256 +            self._ng = self._srp.NG_1024 +            # **************************************************** # + +            self._session = self._fetcher.session() +            self._session_id = None +            self._session_id_lock = QtCore.QMutex() +            self._uid = None +            self._uid_lock = QtCore.QMutex() +            self._token = None +            self._token_lock = QtCore.QMutex() + +            self._srp_user = None +            self._srp_a = None + +        def _safe_unhexlify(self, val): +            """ +            Rounds the val to a multiple of 2 and returns the +            unhexlified value + +            :param val: hexlified value +            :type val: str + +            :rtype: binary hex data +            :return: unhexlified val +            """ +            return binascii.unhexlify(val) \ +                if (len(val) % 2 == 0) else binascii.unhexlify('0' + val) + +        def _authentication_preprocessing(self, username, password): +            """ +            Generates the SRP.User to get the A SRP parameter + +            :param username: username to login +            :type username: str +            :param password: password for the username +            :type password: str +            """ +            logger.debug("Authentication preprocessing...") +            self._srp_user = self._srp.User(username, +                                            password, +                                            self._hashfun, +                                            self._ng) +            _, A = self._srp_user.start_authentication() + +            self._srp_a = A + +        def _start_authentication(self, _, username): +            """ +            Sends the first request for authentication to retrieve the +            salt and B parameter + +            Might raise all SRPAuthenticationError based: +              SRPAuthenticationError +              SRPAuthConnectionError +              SRPAuthUnknownUser +              SRPAuthBadStatusCode +              SRPAuthNoSalt +              SRPAuthNoB + +            :param _: IGNORED, output from the previous callback (None) +            :type _: IGNORED +            :param username: username to login +            :type username: str + +            :return: salt and B parameters +            :rtype: tuple +            """ +            logger.debug("Starting authentication process...") +            try: +                auth_data = { +                    self.LOGIN_KEY: username, +                    self.A_KEY: binascii.hexlify(self._srp_a) +                } +                sessions_url = "%s/%s/%s/" % \ +                    (self._provider_config.get_api_uri(), +                     self._provider_config.get_api_version(), +                     "sessions") +                init_session = self._session.post(sessions_url, +                                                  data=auth_data, +                                                  verify=self._provider_config. +                                                  get_ca_cert_path(), +                                                  timeout=REQUEST_TIMEOUT) +                # Clean up A value, we don't need it anymore +                self._srp_a = None +            except requests.exceptions.ConnectionError as e: +                logger.error("No connection made (salt): %r" % +                             (e,)) +                raise SRPAuthConnectionError("Could not establish a " +                                             "connection") +            except Exception as e: +                logger.error("Unknown error: %r" % (e,)) +                raise SRPAuthenticationError("Unknown error: %r" % +                                             (e,)) + +            content, mtime = reqhelper.get_content(init_session) + +            if init_session.status_code not in (200,): +                logger.error("No valid response (salt): " +                             "Status code = %r. Content: %r" % +                             (init_session.status_code, content)) +                if init_session.status_code == 422: +                    raise SRPAuthUnknownUser(self.tr("Unknown user")) + +                raise SRPAuthBadStatusCode(self.tr("There was a problem with" +                                                   " authentication")) + +            json_content = json.loads(content) +            salt = json_content.get("salt", None) +            B = json_content.get("B", None) + +            if salt is None: +                logger.error("No salt parameter sent") +                raise SRPAuthNoSalt(self.tr("The server did not send " +                                            "the salt parameter")) +            if B is None: +                logger.error("No B parameter sent") +                raise SRPAuthNoB(self.tr("The server did not send " +                                         "the B parameter")) + +            return salt, B + +        def _process_challenge(self, salt_B, username): +            """ +            Given the salt and B processes the auth challenge and +            generates the M2 parameter + +            Might raise SRPAuthenticationError based: +              SRPAuthenticationError +              SRPAuthBadDataFromServer +              SRPAuthConnectionError +              SRPAuthJSONDecodeError +              SRPAuthBadPassword + +            :param salt_B: salt and B parameters for the username +            :type salt_B: tuple +            :param username: username for this session +            :type username: str + +            :return: the M2 SRP parameter +            :rtype: str +            """ +            logger.debug("Processing challenge...") +            try: +                salt, B = salt_B +                unhex_salt = self._safe_unhexlify(salt) +                unhex_B = self._safe_unhexlify(B) +            except (TypeError, ValueError) as e: +                logger.error("Bad data from server: %r" % (e,)) +                raise SRPAuthBadDataFromServer( +                    self.tr("The data sent from the server had errors")) +            M = self._srp_user.process_challenge(unhex_salt, unhex_B) + +            auth_url = "%s/%s/%s/%s" % (self._provider_config.get_api_uri(), +                                        self._provider_config. +                                        get_api_version(), +                                        "sessions", +                                        username) + +            auth_data = { +                self.CLIENT_AUTH_KEY: binascii.hexlify(M) +            } + +            try: +                auth_result = self._session.put(auth_url, +                                                data=auth_data, +                                                verify=self._provider_config. +                                                get_ca_cert_path(), +                                                timeout=REQUEST_TIMEOUT) +            except requests.exceptions.ConnectionError as e: +                logger.error("No connection made (HAMK): %r" % (e,)) +                raise SRPAuthConnectionError(self.tr("Could not connect to " +                                                     "the server")) + +            try: +                content, mtime = reqhelper.get_content(auth_result) +            except JSONDecodeError: +                raise SRPAuthJSONDecodeError("Bad JSON content in auth result") + +            if auth_result.status_code == 422: +                error = "" +                try: +                    error = json.loads(content).get("errors", "") +                except ValueError: +                    logger.error("Problem parsing the received response: %s" +                                 % (content,)) +                except AttributeError: +                    logger.error("Expecting a dict but something else was " +                                 "received: %s", (content,)) +                logger.error("[%s] Wrong password (HAMK): [%s]" % +                             (auth_result.status_code, error)) +                raise SRPAuthBadPassword(self.tr("Wrong password")) + +            if auth_result.status_code not in (200,): +                logger.error("No valid response (HAMK): " +                             "Status code = %s. Content = %r" % +                             (auth_result.status_code, content)) +                raise SRPAuthBadStatusCode(self.tr("Unknown error (%s)") % +                                           (auth_result.status_code,)) + +            return json.loads(content) + +        def _extract_data(self, json_content): +            """ +            Extracts the necessary parameters from json_content (M2, +            id, token) + +            Might raise SRPAuthenticationError based: +              SRPBadDataFromServer + +            :param json_content: Data received from the server +            :type json_content: dict +            """ +            try: +                M2 = json_content.get("M2", None) +                uid = json_content.get("id", None) +                token = json_content.get("token", None) +            except Exception as e: +                logger.error(e) +                raise SRPAuthBadDataFromServer("Something went wrong with the " +                                               "login") + +            self.set_uid(uid) +            self.set_token(token) + +            if M2 is None or self.get_uid() is None: +                logger.error("Something went wrong. Content = %r" % +                             (json_content,)) +                raise SRPAuthBadDataFromServer(self.tr("Problem getting data " +                                                       "from server")) + +            events_signal( +                proto.CLIENT_UID, content=uid, +                reqcbk=lambda req, res: None)  # make the rpc call async + +            return M2 + +        def _verify_session(self, M2): +            """ +            Verifies the session based on the M2 parameter. If the +            verification succeeds, it sets the session_id for this +            session + +            Might raise SRPAuthenticationError based: +              SRPAuthBadDataFromServer +              SRPAuthVerificationFailed + +            :param M2: M2 SRP parameter +            :type M2: str +            """ +            logger.debug("Verifying session...") +            try: +                unhex_M2 = self._safe_unhexlify(M2) +            except TypeError: +                logger.error("Bad data from server (HAWK)") +                raise SRPAuthBadDataFromServer(self.tr("Bad data from server")) + +            self._srp_user.verify_session(unhex_M2) + +            if not self._srp_user.authenticated(): +                logger.error("Auth verification failed") +                raise SRPAuthVerificationFailed(self.tr("Auth verification " +                                                        "failed")) +            logger.debug("Session verified.") + +            session_id = self._session.cookies.get(self.SESSION_ID_KEY, None) +            if not session_id: +                logger.error("Bad cookie from server (missing _session_id)") +                raise SRPAuthNoSessionId(self.tr("Session cookie " +                                                 "verification " +                                                 "failed")) + +            events_signal( +                proto.CLIENT_SESSION_ID, content=session_id, +                reqcbk=lambda req, res: None)  # make the rpc call async + +            self.set_session_id(session_id) + +        def _threader(self, cb, res, *args, **kwargs): +            return threads.deferToThread(cb, res, *args, **kwargs) + +        def authenticate(self, username, password): +            """ +            Executes the whole authentication process for a user + +            Might raise SRPAuthenticationError + +            :param username: username for this session +            :type username: str +            :param password: password for this user +            :type password: str + +            :returns: A defer on a different thread +            :rtype: twisted.internet.defer.Deferred +            """ +            leap_assert(self.get_session_id() is None, "Already logged in") + +            d = threads.deferToThread(self._authentication_preprocessing, +                                      username=username, +                                      password=password) + +            d.addCallback( +                partial(self._threader, +                        self._start_authentication), +                username=username) +            d.addCallback( +                partial(self._threader, +                        self._process_challenge), +                username=username) +            d.addCallback( +                partial(self._threader, +                        self._extract_data)) +            d.addCallback(partial(self._threader, +                                  self._verify_session)) + +            return d + +        def logout(self): +            """ +            Logs out the current session. +            Expects a session_id to exists, might raise AssertionError +            """ +            logger.debug("Starting logout...") + +            leap_assert(self.get_session_id(), +                        "Cannot logout an unexisting session") + +            logout_url = "%s/%s/%s/" % (self._provider_config.get_api_uri(), +                                        self._provider_config. +                                        get_api_version(), +                                        "sessions") +            try: +                self._session.delete(logout_url, +                                     data=self.get_session_id(), +                                     verify=self._provider_config. +                                     get_ca_cert_path(), +                                     timeout=REQUEST_TIMEOUT) +            except Exception as e: +                logger.warning("Something went wrong with the logout: %r" % +                               (e,)) + +            self.set_session_id(None) +            self.set_uid(None) +            # Also reset the session +            self._session = self._fetcher.session() +            logger.debug("Successfully logged out.") + +        def set_session_id(self, session_id): +            QtCore.QMutexLocker(self._session_id_lock) +            self._session_id = session_id + +        def get_session_id(self): +            QtCore.QMutexLocker(self._session_id_lock) +            return self._session_id + +        def set_uid(self, uid): +            QtCore.QMutexLocker(self._uid_lock) +            self._uid = uid + +        def get_uid(self): +            QtCore.QMutexLocker(self._uid_lock) +            return self._uid + +        def set_token(self, token): +            QtCore.QMutexLocker(self._token_lock) +            self._token = token + +        def get_token(self): +            QtCore.QMutexLocker(self._token_lock) +            return self._token + +    __instance = None + +    authentication_finished = QtCore.Signal(bool, str) +    logout_finished = QtCore.Signal(bool, str) + +    def __init__(self, provider_config): +        """ +        Creates a singleton instance if needed +        """ +        QtCore.QObject.__init__(self) + +        # Check whether we already have an instance +        if SRPAuth.__instance is None: +            # Create and remember instance +            SRPAuth.__instance = SRPAuth.__impl(provider_config) + +        # Store instance reference as the only member in the handle +        self.__dict__['_SRPAuth__instance'] = SRPAuth.__instance + +    def authenticate(self, username, password): +        """ +        Executes the whole authentication process for a user + +        Might raise SRPAuthenticationError based + +        :param username: username for this session +        :type username: str +        :param password: password for this user +        :type password: str +        """ + +        d = self.__instance.authenticate(username, password) +        d.addCallback(self._gui_notify) +        d.addErrback(self._errback) +        return d + +    def _gui_notify(self, _): +        """ +        Callback that notifies the UI with the proper signal. + +        :param _: IGNORED, output from the previous callback (None) +        :type _: IGNORED +        """ +        logger.debug("Successful login!") +        self.authentication_finished.emit(True, self.tr("Succeeded")) + +    def _errback(self, failure): +        """ +        General errback for the whole login process. Will notify the +        UI with the proper signal. + +        :param failure: Failure object captured from a callback. +        :type failure: twisted.python.failure.Failure +        """ +        logger.error("Error logging in %s" % (failure,)) +        self.authentication_finished.emit(False, "%s" % (failure.value,)) +        failure.trap(Exception) + +    def get_session_id(self): +        return self.__instance.get_session_id() + +    def get_uid(self): +        return self.__instance.get_uid() + +    def get_token(self): +        return self.__instance.get_token() + +    def logout(self): +        """ +        Logs out the current session. +        Expects a session_id to exists, might raise AssertionError +        """ +        try: +            self.__instance.logout() +            self.logout_finished.emit(True, self.tr("Succeeded")) +            return True +        except Exception as e: +            self.logout_finished.emit(False, "%s" % (e,)) +        return False | 
