CAT-SOOP is a flexible, programmable learning management system based on the Python programming language. https://catsoop.mit.edu
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.
 
 
 

230 lines
8.9 KiB

  1. # This file is part of CAT-SOOP
  2. # Copyright (c) 2011-2020 by The CAT-SOOP Developers <catsoop-dev@mit.edu>
  3. #
  4. # This program is free software: you can redistribute it and/or modify it under
  5. # the terms of the GNU Affero General Public License as published by the Free
  6. # Software Foundation, either version 3 of the License, or (at your option) any
  7. # later version.
  8. #
  9. # This program is distributed in the hope that it will be useful, but WITHOUT
  10. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
  11. # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
  12. # details.
  13. #
  14. # You should have received a copy of the GNU Affero General Public License
  15. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  16. import sys
  17. import json
  18. import base64
  19. import urllib.request, urllib.parse, urllib.error
  20. import jose.jwk
  21. import jose.utils
  22. stored_state = cs_session_data.get("_openid_state", None)
  23. state = cs_form.get("state", None)
  24. stored_nonce = cs_session_data.get("_openid_nonce", None)
  25. error = None
  26. if stored_state is None:
  27. error = "No state stored in session."
  28. elif state is None:
  29. error = "No state provided by server."
  30. elif stored_state != state:
  31. error = "Suspected tampering! " "State from server does not match local state."
  32. if error is None:
  33. # we should have course information in the session data, so we can see if
  34. # we need to do a preload.
  35. ctx = {}
  36. csm_loader.load_global_data(ctx)
  37. session = cs_session_data
  38. if "_openid_course" in session:
  39. ctx["cs_course"] = cs_session_data["_openid_course"]
  40. ctx["cs_path_info"] = [ctx["cs_course"]]
  41. cfile = csm_dispatch.content_file_location(ctx, [ctx["cs_course"]])
  42. csm_loader.do_preload(ctx, ctx["cs_course"], [], ctx, cfile)
  43. # if we're here, we know we got back something reasonable.
  44. # now, need to send POST request
  45. id = ctx.get("cs_openid_client_id", "")
  46. secret = ctx.get("cs_openid_client_secret", "")
  47. redir_url = "%s/_auth/openid_connect/callback" % ctx["cs_url_root"]
  48. data = urllib.parse.urlencode(
  49. {
  50. "grant_type": "authorization_code",
  51. "code": cs_form["code"],
  52. "redirect_uri": redir_url,
  53. "client_id": id,
  54. "client_secret": secret,
  55. }
  56. ).encode()
  57. request = urllib.request.Request(
  58. cs_session_data["_openid_config"]["token_endpoint"]
  59. )
  60. try:
  61. resp = urllib.request.urlopen(request, data).read()
  62. except:
  63. error = "Server rejected Token request."
  64. if error is None:
  65. try:
  66. resp = json.loads(resp.decode())
  67. except:
  68. error = "Token request response was not valid JSON."
  69. if error is None:
  70. # make sure we have been given authorization to access the proper
  71. # information
  72. desired_scope = ctx.get("cs_openid_scope", "openid profile email")
  73. desired_scope = desired_scope.split()
  74. scope_error = (
  75. "You must provide CAT-SOOP access " "to the following scopes: %r"
  76. ) % desired_scope
  77. if "id_token" not in resp or any(
  78. i not in resp.get("scope", "").split() for i in desired_scope
  79. ):
  80. error = scope_error
  81. if error is None:
  82. # verify JWT signature
  83. id_token, sig = resp["id_token"].rsplit(".", 1)
  84. if error is None:
  85. # get JWKs from the web
  86. url = cs_session_data["_openid_config"]["jwks_uri"]
  87. try:
  88. keys = json.loads(urllib.request.urlopen(url).read().decode())["keys"]
  89. except:
  90. error = "Server rejected request for JWK"
  91. # this is a really gross way to try all key/alg pairs, and to set
  92. # the 'error' variable if none worked, but to skip on otherwise.
  93. # forgive the gross control flow...see comments below for what's
  94. # happening. main loop loops over all keys.
  95. for key in keys:
  96. if key.get("use", None) == "enc":
  97. # if this is specified as an encryption key, don't bother.
  98. continue
  99. # i don't know if there's a nicer way to do this, but for now,
  100. # just loop over all possible algorithms since i don't know
  101. # which one was used.
  102. for alg in cs_session_data["_openid_config"][
  103. "id_token_signing_alg_values_supported"
  104. ]:
  105. try:
  106. key = jose.jwk.construct(key, alg)
  107. decoded_sig = jose.utils.base64url_decode(sig.encode())
  108. if key.verify(id_token.encode(), decoded_sig):
  109. # found a working key! break out of this loop.
  110. break
  111. except:
  112. continue
  113. else:
  114. # breaking out of the loop is the signal that we won. so
  115. # if we _didn't_ get out of the last loop via `break`,
  116. # we'll be here. in this case, we should move on to the
  117. # next key (continue)
  118. continue
  119. # we'll reach this point if we _did_ break out of the inner
  120. # loop (i.e., if we found a working key). in that case, we
  121. # want to break out of this loop as well (to avoid the else
  122. # clause).
  123. break
  124. else:
  125. # if we're here, we didn't find a valid key/alg pair, so error
  126. # out.
  127. error = "Invalid signature on JWS."
  128. if error is None:
  129. # check information from ID Token
  130. def _b64_pad(s, char="="):
  131. missing = len(s) % 4
  132. if not missing:
  133. return s
  134. return s + char * (4 - missing)
  135. header, body = id_token.split(".")
  136. header = _b64_pad(header)
  137. body = _b64_pad(body)
  138. try:
  139. header = json.loads(base64.b64decode(header).decode())
  140. body = json.loads(base64.b64decode(body).decode())
  141. except:
  142. error = "Malformed header and/or body of ID token"
  143. if error is None:
  144. now = time.time.time()
  145. if body["iss"].rstrip("/") != ctx.get("cs_openid_server", None):
  146. error = "Invalid ID Token issuer."
  147. elif body["nonce"] != stored_nonce:
  148. error = (
  149. "Suspected tampering!"
  150. "Nonce from server does not match local nonce."
  151. )
  152. elif body["iat"] > now + 60:
  153. error = "ID Token is from the future. %r" % ((body["iat"], now),)
  154. elif now > body["exp"] + 60:
  155. error = "ID Token has expired."
  156. elif ctx.get("cs_openid_client_id", None) not in body["aud"]:
  157. error = "ID Token is not intended for CAT-SOOP."
  158. if error is None:
  159. # get user information from server
  160. access_tok = resp["access_token"]
  161. redir = cs_session_data["_openid_config"]["userinfo_endpoint"]
  162. headers = {"Authorization": "Bearer %s" % access_tok}
  163. request2 = urllib.request.Request(redir, headers=headers)
  164. try:
  165. resp = json.loads(urllib.request.urlopen(request2).read().decode())
  166. except:
  167. error = "Server rejected request for User Information"
  168. if error is None:
  169. # try to set usert information in session
  170. def get_username(idtoken, userinfo):
  171. try:
  172. return userinfo["preferred_username"]
  173. except:
  174. return userinfo["email"].split("@")[0]
  175. def get_email(idtoken, userinfo):
  176. return userinfo["email"]
  177. try:
  178. get_username = ctx.get("cs_openid_username_generator", get_username)
  179. get_email = ctx.get("cs_openid_email_generator", get_email)
  180. openid_info = {
  181. "username": get_username(body, resp),
  182. "email": get_email(body, resp),
  183. "name": resp["name"],
  184. }
  185. session.update(openid_info)
  186. session["course"] = cs_session_data["_openid_course"]
  187. except:
  188. error = "Error setting user information."
  189. path = [csm_base_context.cs_url_root] + cs_session_data.get("_openid_path", ["/"])
  190. redirect_location = "/".join(path)
  191. if cs_session_data.get("cs_query_string", ""):
  192. redirect_location += "?" + cs_session_data["cs_query_string"]
  193. if error is None:
  194. # we made it! set session data and redirect to original page
  195. csm_session.set_session_data(globals(), cs_sid, session)
  196. csm_cslog.overwrite_log("_extra_info", [], session["username"], openid_info)
  197. cs_handler = "redirect"
  198. else:
  199. cs_handler = "passthrough"
  200. cs_content_header = "Could Not Log You In"
  201. cs_content = (
  202. 'You could not be logged in to the system because of the following error:<br/><font color="red">%s</font><p>Click <a href="%s?loginaction=login">here</a> to try again.'
  203. % (error, redirect_location)
  204. )
  205. cs_footer = cs_footer.replace(cs_base_logo_text, csm_errors.error_500_logo)