A corporation manager and dashboard for EVE Online
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.

auth.py 14 KiB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. # -*- coding: utf-8 -*-
  2. from datetime import datetime, timedelta, timezone
  3. from flask import g, session, url_for
  4. from itsdangerous import BadSignature, URLSafeSerializer
  5. from . import baseLogger
  6. from .exceptions import AccessDeniedError
  7. __all__ = ["AuthManager"]
  8. class AuthManager:
  9. """Authentication manager. Handles user access and management."""
  10. EXPIRY_THRESHOLD = 30
  11. def __init__(self, config, eve):
  12. self._config = config
  13. self._eve = eve
  14. self._logger = baseLogger.getChild("auth")
  15. self._debug = self._logger.debug
  16. def _allocate_new_session(self):
  17. """Create a new session for the current user."""
  18. sid, created = g.db.new_session()
  19. session["id"] = sid
  20. session["date"] = int(created.replace(tzinfo=timezone.utc).timestamp())
  21. self._debug("Allocated session id=%d", sid)
  22. g._session_check = True
  23. g._session_expired = False
  24. def _get_session_id(self):
  25. """Return the current session ID, allocating a new one if necessary."""
  26. if "id" not in session:
  27. self._allocate_new_session()
  28. return session["id"]
  29. def _invalidate_session(self):
  30. """Mark the current session as invalid.
  31. Remove it from the database and from the user's cookies.
  32. """
  33. if "id" in session:
  34. sid = session["id"]
  35. g.db.drop_session(sid)
  36. self._debug("Dropped session id=%d", sid)
  37. session.clear()
  38. def _expire_session(self, always_notify=False):
  39. """Mark the current session as expired, then invalidate it."""
  40. if always_notify or session.get("expire-notify"):
  41. g._session_expired = True
  42. self._debug("Session expired id=%d", session["id"])
  43. self._invalidate_session()
  44. def _check_session(self, always_notify_expired=False):
  45. """Return whether the user has a valid, non-expired session.
  46. This checks for the session existing in the database, but does not
  47. check that the user is logged in or has any particular access roles.
  48. """
  49. if "id" not in session:
  50. return False
  51. if hasattr(g, "_session_check"):
  52. return g._session_check
  53. if "date" not in session:
  54. self._debug("Clearing dateless session id=%d", session["id"])
  55. session.clear()
  56. return False
  57. created = g.db.has_session(session["id"])
  58. if not created:
  59. self._expire_session(always_notify=always_notify_expired)
  60. g._session_check = False
  61. return False
  62. cstamp = int(created.replace(tzinfo=timezone.utc).timestamp())
  63. if session["date"] != cstamp:
  64. self._debug("Clearing bad-date session id=%d", session["id"])
  65. session.clear()
  66. return False
  67. g._session_check = True
  68. return True
  69. def _get_state_hash(self):
  70. """Return a hash of the user's session ID suitable for OAuth2 state.
  71. Allocates a new session ID if necessary.
  72. """
  73. key = self._config.get("auth.session_key")
  74. serializer = URLSafeSerializer(key)
  75. return serializer.dumps(self._get_session_id())
  76. def _verify_state_hash(self, state):
  77. """Confirm that a state hash is correct for the user's session.
  78. Assumes we've already checked the session ID. If the state is invalid,
  79. the session will be invalidated.
  80. """
  81. key = self._config.get("auth.session_key")
  82. serializer = URLSafeSerializer(key)
  83. try:
  84. value = serializer.loads(state)
  85. except BadSignature:
  86. self._debug("Bad signature for session id=%d", session["id"])
  87. self._invalidate_session()
  88. return False
  89. if value != session["id"]:
  90. self._debug("Got session id=%d, expected id=%d", value,
  91. session["id"])
  92. self._invalidate_session()
  93. return False
  94. return True
  95. def _fetch_new_token(self, code, refresh=False):
  96. """Given an auth code or refresh token, get a new token and other data.
  97. If refresh is True, code should be a refresh token, otherwise an auth
  98. code. If successful, we'll return a 5-tuple of (access_token,
  99. token_expiry, refresh_token, char_id, char_name). If the token was
  100. invalid, we'll return None. We may also raise EVEAPIError if there was
  101. an internal API error.
  102. """
  103. cid = self._config.get("auth.client_id")
  104. secret = self._config.get("auth.client_secret")
  105. result = self._eve.sso.get_access_token(cid, secret, code, refresh)
  106. if not result:
  107. return None
  108. token, expiry, refresh = result
  109. expires = (datetime.utcnow().replace(microsecond=0) +
  110. timedelta(seconds=expiry))
  111. result = self._eve.sso.get_character_info(token)
  112. if not result:
  113. return None
  114. char_id, char_name = result
  115. return token, expires, refresh, char_id, char_name
  116. def _get_token(self, cid):
  117. """Return a valid access token for the given character, or None.
  118. If the database doesn't have an auth entry for this character, return
  119. None. If the database's token is expired but the refresh token is
  120. valid, then refresh it, update the database, and return the new token.
  121. If the token has become invalid and couldn't be refreshed, drop the
  122. auth information from the database and return None.
  123. """
  124. result = g.db.get_auth(cid)
  125. if not result:
  126. self._debug("No auth info in database for char id=%d", cid)
  127. return None
  128. token, expires, refresh = result
  129. seconds_til_expiry = (expires - datetime.utcnow()).total_seconds()
  130. if seconds_til_expiry >= self.EXPIRY_THRESHOLD:
  131. self._debug("Using cached access token for char id=%d", cid)
  132. return token
  133. result = self._fetch_new_token(refresh, refresh=True)
  134. if not result:
  135. self._debug("Couldn't refresh token for char id=%d", cid)
  136. g.db.drop_auth(cid)
  137. return None
  138. token, expires, refresh, char_id, char_name = result
  139. if char_id != cid:
  140. self._debug("Refreshed token has incorrect char id=%d for "
  141. "char id=%d", char_id, cid)
  142. g.db.drop_auth(cid)
  143. return None
  144. self._debug("Using fresh access token for char id=%d", cid)
  145. g.db.put_character(cid, char_name)
  146. g.db.update_auth(cid, token, expires, refresh)
  147. return token
  148. def _check_access(self, token, char_id):
  149. """"Check whether the given character is allowed to access this site.
  150. If allowed, do nothing. If not, raise AccessDeniedError.
  151. """
  152. resp = self._eve.esi(token).v3.characters(char_id).get()
  153. if resp.get("corporation_id") != self._config.get("corp.id"):
  154. self._debug("Access denied per corp membership for char id=%d "
  155. "session id=%d", char_id, session["id"])
  156. g.db.drop_auth(char_id)
  157. self._invalidate_session()
  158. raise AccessDeniedError()
  159. def _update_prop_cache(self, module, prop, value):
  160. """Update the value of a character module property in the cache."""
  161. if hasattr(g, "_character_modprops"):
  162. propcache = g._character_modprops
  163. else:
  164. propcache = g._character_modprops = {module: {}}
  165. if module not in propcache:
  166. propcache[module] = {}
  167. propcache[module][prop] = value
  168. def get_character_id(self):
  169. """Return the character ID associated with the current session.
  170. Return None if the session is invalid or is not associated with a
  171. character.
  172. """
  173. if not self._check_session():
  174. return None
  175. if not hasattr(g, "_character_id"):
  176. g._character_id = g.db.read_session(session["id"])
  177. return g._character_id
  178. def get_character_prop(self, prop):
  179. """Look up a property for the current session's character.
  180. Return None if the session is invalid, is not associated with a
  181. character, or the property has no non-default value.
  182. """
  183. cid = self.get_character_id()
  184. if not cid:
  185. return None
  186. if not hasattr(g, "_character_props"):
  187. g._character_props = g.db.read_character(cid)
  188. return g._character_props.get(prop)
  189. def set_character_style(self, style):
  190. """Update the current user's style and return whether successful."""
  191. cid = self.get_character_id()
  192. if not cid:
  193. return False
  194. style = style.strip().lower()
  195. if style not in self._config.get("style.enabled"):
  196. return False
  197. self._debug("Setting style to %s for char id=%d", style, cid)
  198. g.db.update_character(cid, "style", style)
  199. if hasattr(g, "_character_props"):
  200. delattr(g, "_character_props")
  201. return True
  202. def get_character_modprop(self, module, prop):
  203. """Look up a module property for the current session's character.
  204. Return None if the session is invalid, is not associated with a
  205. character, or the property has no non-default value.
  206. """
  207. cid = self.get_character_id()
  208. if not cid:
  209. return None
  210. if hasattr(g, "_character_modprops"):
  211. propcache = g._character_modprops
  212. if module in propcache and prop in propcache[module]:
  213. return propcache[module][prop]
  214. value = g.db.get_character_modprop(cid, module, prop)
  215. self._update_prop_cache(module, prop, value)
  216. return value
  217. def set_character_modprop(self, module, prop, value):
  218. """Update a module property for the current session's character.
  219. Return whether successful.
  220. """
  221. cid = self.get_character_id()
  222. if not cid:
  223. return False
  224. self._debug("Setting module %s property %s to %s for char id=%d",
  225. module, prop, value, cid)
  226. g.db.set_character_modprop(cid, module, prop, value)
  227. self._update_prop_cache(module, prop, value)
  228. return True
  229. def get_token(self):
  230. """Return a valid token for the current character, or None.
  231. Assuming this is called in a restricted route (following a True result
  232. from is_authenticated), this function makes no API calls and should
  233. always succeed. If it is called in other circumstances, it may fail and
  234. return None.
  235. """
  236. cid = self.get_character_id()
  237. if not cid:
  238. return None
  239. if not hasattr(g, "_cached_token"):
  240. g._cached_token = self._get_token(cid)
  241. return g._cached_token
  242. def is_authenticated(self):
  243. """Return whether the user has permission to access this site.
  244. We confirm that they have a valid, non-expired session that is
  245. associated with a character that is permitted to be here.
  246. EVEAPIError or AccessDeniedError may be raised.
  247. """
  248. cid = self.get_character_id()
  249. if not cid:
  250. return False
  251. self._debug("Checking auth for session id=%d", session["id"])
  252. token = self._get_token(cid)
  253. if not token:
  254. self._debug("No valid token for char id=%d session id=%d", cid,
  255. session["id"])
  256. self._invalidate_session()
  257. return False
  258. self._check_access(token, cid)
  259. self._debug("Access granted for char id=%d session id=%d", cid,
  260. session["id"])
  261. g.db.touch_session(session["id"])
  262. g._cached_token = token
  263. return True
  264. def make_login_link(self):
  265. """Return a complete EVE SSO link that the user can use to log in."""
  266. cid = self._config.get("auth.client_id")
  267. target = url_for("login", _external=True, _scheme=self._config.scheme)
  268. scopes = self._config.collect_scopes()
  269. state = self._get_state_hash()
  270. return self._eve.sso.get_authorize_url(cid, target, scopes, state)
  271. def handle_login(self, code, state):
  272. """Given an OAuth2 code and state, try to authenticate the user.
  273. If the user has a legitimate session and the state is valid, we'll
  274. check the code with EVE SSO to fetch an authentication token. If the
  275. token corresponds to a character that is allowed to access the site,
  276. we'll update their session to indicate so.
  277. Return whether authentication was successful. EVEAPIError or
  278. AccessDeniedError may be raised.
  279. """
  280. if not code or not state:
  281. return False
  282. if "id" in session:
  283. self._debug("Logging in session id=%d", session["id"])
  284. if not self._check_session(always_notify_expired=True):
  285. return False
  286. if not self._verify_state_hash(state):
  287. return False
  288. sid = session["id"]
  289. result = self._fetch_new_token(code)
  290. if not result:
  291. self._debug("Couldn't fetch token for session id=%d", sid)
  292. self._invalidate_session()
  293. return False
  294. token, expires, refresh, char_id, char_name = result
  295. self._check_access(token, char_id)
  296. self._debug("Logged in char id=%d session id=%d", char_id, sid)
  297. g.db.put_character(char_id, char_name)
  298. g.db.set_auth(char_id, token, expires, refresh)
  299. g.db.attach_session(sid, char_id)
  300. g.db.touch_session(sid)
  301. session["expire-notify"] = True
  302. return True
  303. def handle_logout(self):
  304. """Log out the user if they are logged in.
  305. Invalidates their session and clears the session cookie.
  306. """
  307. if "id" in session:
  308. self._debug("Logging out session id=%d", session["id"])
  309. self._invalidate_session()