No Description
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

client.py 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. #
  2. # This file is part of Lodel 2 (https://github.com/OpenEdition)
  3. #
  4. # Copyright (C) 2015-2017 Cléo UMS-3287
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU Affero General Public License as published
  8. # by the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU Affero General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU Affero General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. #
  19. import copy
  20. import sys
  21. import warnings
  22. import inspect
  23. from lodel.context import LodelContext
  24. LodelContext.expose_modules(globals(), {
  25. 'lodel.settings': ['Settings'],
  26. 'lodel.logger': 'logger',
  27. 'lodel.plugin': [('SessionHandlerPlugin', 'SessionHandler')],
  28. 'lodel.auth.exceptions': ['ClientError', 'ClientAuthenticationFailure',
  29. 'ClientPermissionDenied', 'ClientAuthenticationError'],
  30. 'lodel.leapi.query': ['LeGetQuery'], })
  31. ## @brief Client metaclass designed to implements container accessor on
  32. # Client Class
  33. #
  34. #@todo Maybe we can delete this metaclass....
  35. class ClientMetaclass(type):
  36. def __init__(self, name, bases, attrs):
  37. return super(ClientMetaclass, self).__init__(name, bases, attrs)
  38. def __getitem__(self, key):
  39. return self.data()[key]
  40. def __delitem__(self, key):
  41. del(self.data()[key])
  42. def __setitem__(self, key, value):
  43. if self.get_session_token() is None:
  44. self.set_session_token(SessionHandler.start())
  45. data = self.data()
  46. data[key] = value
  47. def __str__(self):
  48. return str(self._instance)
  49. ## @brief Abstract singleton class designed to handle client informations
  50. #
  51. # This class is designed to handle client authentication and sessions
  52. class Client(object, metaclass=ClientMetaclass):
  53. ## @brief Singleton instance
  54. _instance = None
  55. # @brief List of dict that stores field ref for login and password
  56. #
  57. # Storage specs :
  58. #
  59. # A list of dict, with keys 'login' and 'password', items are tuple.
  60. # - login tuple contains (LeObjectChild, FieldName, link_field) with:
  61. # - LeObjectChild the dynclass containing the login
  62. # - Fieldname the fieldname of LeObjectChild containing the login
  63. # - link_field None if both login and password are in the same
  64. # LeObjectChild. Else contains the field that make the link between
  65. # login LeObject and password LeObject
  66. # - password typle contains (LeObjectChild, FieldName)
  67. _infos_fields = None
  68. ## @brief Constant that stores the session key that stores authentication
  69. # informations
  70. _AUTH_DATANAME = '__auth_user_infos'
  71. ## @brief Constructor
  72. #@param session_token mixed : Session token provided by client to interface
  73. def __init__(self, session_token=None):
  74. logger.debug(session_token)
  75. if self.__class__ == Client:
  76. raise NotImplementedError("Abstract class")
  77. logger.debug("New instance of Client child class %s" %
  78. self.__class__.__name__)
  79. if Client._instance is not None:
  80. old = Client._instance
  81. Client._instance = None
  82. del(old)
  83. logger.debug("Replacing old Client instance by a new one")
  84. else:
  85. ## first instanciation, fetching settings
  86. self.fetch_settings()
  87. # @brief Stores infos for authenticated users (None == anonymous)
  88. self.__user = None
  89. ## @brief Stores the session handler
  90. Client._instance = self
  91. ## @brief Stores LodelSession instance
  92. self.__data = dict()
  93. if session_token is not None:
  94. self.__data = SessionHandler.restore(session_token)
  95. self.__session_token = session_token
  96. logger.debug("New client : %s" % self)
  97. def __del__(self):
  98. del(self.__session_token)
  99. del(self.__data)
  100. ## @brief Returns session
  101. #@ returns the dict which stores session
  102. @classmethod
  103. def data(cls):
  104. return cls._instance.__data
  105. ## @brief Returns the user's information contained in the session's data
  106. @classmethod
  107. def user(cls):
  108. if '__auth_user_infos' in cls._instance.__data:
  109. return cls._instance.__data['__auth_user_infos']
  110. else:
  111. return None
  112. ## @brief Returns the session's token
  113. @classmethod
  114. def get_session_token(cls):
  115. return cls._instance.__session_token
  116. ## @brief Set the session's token
  117. #@param the value of the token
  118. @classmethod
  119. def set_session_token(cls, value):
  120. cls._instance.__session_token = value
  121. ## @brief Try to authenticate a user with a login and a password
  122. #@param login str : provided login
  123. #@param password str : provided password (hash)
  124. #@warning brokes composed UID
  125. #@note implements multiple login/password sources (useless ?)
  126. #@todo composed UID broken method
  127. #@todo allow to provide an authentication source
  128. @classmethod
  129. def authenticate(self, login=None, password=None):
  130. # Authenticate
  131. for infos in self._infos_fields:
  132. logger.debug(self._infos_fields)
  133. login_cls = infos['login'][0]
  134. pass_cls = infos['password'][0]
  135. qfilter = "{passfname} = {passhash}"
  136. uid_fname = login_cls.uid_fieldname()[0] # COMPOSED UID BROKEN
  137. if login_cls == pass_cls:
  138. # Same EmClass for login & pass
  139. qfilter = qfilter.format(
  140. passfname=infos['password'][1],
  141. passhash=password)
  142. else:
  143. # Different EmClass, building a relational filter
  144. passfname = "%s.%s" % (infos['login'][2], infos['password'][1])
  145. qfilter = qfilter.format(
  146. passfname=passfname,
  147. passhash=password)
  148. getq = LeGetQuery(infos['login'][0], qfilter,
  149. field_list=[uid_fname], limit=1)
  150. req = getq.execute()
  151. if len(req) == 1:
  152. self.__set_authenticated(infos['login'][0], req[0][uid_fname])
  153. break
  154. if self.is_anonymous():
  155. self.authentication_failure() # Security logging
  156. ## @brief Attempt to restore a session given a session token
  157. #@param token mixed : a session token
  158. #@return Session data (a dict)
  159. #@throw ClientAuthenticationFailure if token is not valid or not
  160. # existing
  161. @classmethod
  162. def restore_session(cls, token):
  163. cls._assert_instance()
  164. if cls._instance.__session_token is not None:
  165. raise ClientAuthenticationError("Trying to restore a session, but \
  166. a session is already started !!!")
  167. try:
  168. cls._instance.__data = SessionHandler.restore(token)
  169. cls._instance.__session_token = token
  170. except ClientAuthenticationFailure:
  171. logger.warning("Session restoring failed")
  172. return copy.copy(cls._instance.data)
  173. ## @brief Returns the current session token or None
  174. #@return A session token or None
  175. @classmethod
  176. def session_token(cls):
  177. cls._assert_instance()
  178. return cls._instance.__session_token
  179. ## @brief Deletes current session
  180. @classmethod
  181. def destroy(cls):
  182. cls._assert_instance()
  183. SessionHandler.destroy(cls._instance.__session_token)
  184. cls._instance.__session_token = None
  185. cls._instance.__data = dict()
  186. ## @brief Deletes current client and saves its session
  187. @classmethod
  188. def clean(cls):
  189. if cls._instance.__session_token is not None:
  190. SessionHandler.save(cls._instance.__session_token, cls._instance.__data)
  191. if Client._instance is not None:
  192. del(Client._instance)
  193. Client._instance = None
  194. ## @brief Tests if a client is anonymous or logged in
  195. #@return True if client is anonymous
  196. @classmethod
  197. def is_anonymous(cls):
  198. return Client._instance.user() is None
  199. ## @brief Method to be called on authentication failure
  200. #@throw ClientAuthenticationFailure
  201. #@throw LodelFatalError if no Client child instance is found
  202. @classmethod
  203. def authentication_failure(cls):
  204. cls._generic_error(ClientAuthenticationFailure)
  205. ## @brief Method to be called on authentication error
  206. #@throw ClientAuthenticationError
  207. #@throw LodelFatalError if no Client child instance is found
  208. @classmethod
  209. def authentication_error(cls, msg="Unknow error"):
  210. cls._generic_error(ClientAuthenticationError, msg)
  211. ## @brief Method to be called on permission denied error
  212. #@throw ClientPermissionDenied
  213. #@throw LodelFatalError if no Client child instance is found
  214. @classmethod
  215. def permission_denied_error(cls, msg=""):
  216. cls._generic_error(ClientPermissionDenied, msg)
  217. ## @brief Generic error method
  218. #@see Client::authentication_failure() Client::authentication_error()
  219. # Client::permission_denied_error()
  220. #@throw LodelFatalError if no Client child instance is found
  221. @classmethod
  222. def _generic_error(cls, expt, msg=""):
  223. cls._assert_instance()
  224. raise expt(Client._instance, msg)
  225. ## @brief Asserts that an instance of Client child class exists
  226. #@throw LodelFataError if no instance of Client child class is found
  227. @classmethod
  228. def _assert_instance(cls):
  229. if Client._instance is None:
  230. raise LodelFatalError("No client instance found. Abording.")
  231. ## @brief Class method that fetches conf
  232. #
  233. # This method populates Client._infos_fields . This attribute stores
  234. # informations on login and password location (LeApi object & field)
  235. @classmethod
  236. def fetch_settings(cls):
  237. LodelContext.expose_dyncode(globals(), 'dyncode')
  238. if cls._infos_fields is None:
  239. cls._infos_fields = list()
  240. else:
  241. # Already fetched
  242. return
  243. infos = (
  244. Settings.auth.login_classfield,
  245. Settings.auth.pass_classfield)
  246. res_infos = []
  247. for clsname, fieldname in infos:
  248. dcls = dyncode.lowername2class(infos[0][0])
  249. res_infos.append((dcls, infos[1][1]))
  250. link_field = None
  251. if res_infos[0][0] != res_infos[1][0]:
  252. # login and password are in two separated EmClass
  253. # determining the field that links login EmClass to password
  254. # EmClass
  255. for fname, fdh in res_infos[0][0].fields(True).items():
  256. if fdh.is_reference() and res_infos[1][0] in fdh.linked_classes():
  257. link_field = fname
  258. if link_field is None:
  259. # Unable to find link between login & password EmClass
  260. raise AuthenticationError("Unable to find a link between \
  261. login EmClass '%s' and password EmClass '%s'. Abording..." % (
  262. res_infos[0][0], res_infos[1][0]))
  263. res_infos[0] = (res_infos[0][0], res_infos[0][1], link_field)
  264. cls._infos_fields.append(
  265. {'login': res_infos[0], 'password': res_infos[1]})
  266. ## @brief Sets a user as authenticated and starts a new session
  267. #@param leo LeObject child class : the LeObject the user is stored in
  268. #@param uid str : uniq id (in leo)
  269. #@return None
  270. @classmethod
  271. def __set_authenticated(cls, leo, uid):
  272. cls._instance.__user = {'classname': leo.__name__, 'uid': uid, 'leoclass': leo}
  273. # Store auth infos in session
  274. cls._instance.__data[cls._instance.__class__._AUTH_DATANAME] = copy.copy(
  275. cls._instance.__user)