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.

463 lines
17 KiB

5 years ago
  1. import logging
  2. import json
  3. import time
  4. import os
  5. import base64
  6. from odoo import http
  7. import werkzeug
  8. from ..api import resource
  9. try:
  10. from jwcrypto import jwk, jwt
  11. from cryptography.hazmat.backends import default_backend
  12. from cryptography.hazmat.primitives import hashes
  13. except ImportError:
  14. pass
  15. _logger = logging.getLogger(__name__)
  16. def jwk_from_json(json_key):
  17. key = jwk.JWK()
  18. key.import_key(**json.loads(json_key))
  19. return key
  20. def jwt_encode(claims, key):
  21. token = jwt.JWT(
  22. header={"alg": key._params["alg"], "kid": key._params["kid"]}, claims=claims
  23. )
  24. token.make_signed_token(key)
  25. return token.serialize()
  26. def jwt_decode(serialized, key):
  27. token = jwt.JWT(jwt=serialized, key=key)
  28. return json.loads(token.claims)
  29. RESPONSE_TYPES_SUPPORTED = ["code", "token", "id_token token", "id_token"]
  30. class OAuthException(Exception):
  31. INVALID_REQUEST = "invalid_request"
  32. INVALID_CLIENT = "invalid_client"
  33. UNSUPPORTED_RESPONSE_TYPE = "unsupported_response_type"
  34. INVALID_GRANT = "invalid_grant"
  35. UNSUPPORTED_GRANT_TYPE = "unsupported_grant_type"
  36. def __init__(self, message, type):
  37. super(Exception, self).__init__(message)
  38. self.type = type
  39. class Main(http.Controller):
  40. def __get_authorization_code_jwk(self, req):
  41. return jwk_from_json(
  42. req.env["ir.config_parameter"]
  43. .sudo()
  44. .get_param("galicea_openid_connect.authorization_code_jwk")
  45. )
  46. def __get_id_token_jwk(self, req):
  47. return jwk_from_json(
  48. req.env["ir.config_parameter"]
  49. .sudo()
  50. .get_param("galicea_openid_connect.id_token_jwk")
  51. )
  52. def __validate_client(self, req, **query):
  53. if "client_id" not in query:
  54. raise OAuthException(
  55. "client_id param is missing", OAuthException.INVALID_CLIENT,
  56. )
  57. client_id = query["client_id"]
  58. client = (
  59. req.env["galicea_openid_connect.client"]
  60. .sudo()
  61. .search([("client_id", "=", client_id)])
  62. )
  63. if not client:
  64. raise OAuthException(
  65. "client_id param is invalid", OAuthException.INVALID_CLIENT,
  66. )
  67. _logger.debug("#### openid client: %s" % client)
  68. return client
  69. def __validate_redirect_uri(self, client, req, **query):
  70. _logger.debug("#### openid client: %s" % query)
  71. if "redirect_uri" not in query:
  72. raise OAuthException(
  73. "redirect_uri param is missing", OAuthException.INVALID_GRANT,
  74. )
  75. redirect_uri = query["redirect_uri"]
  76. _logger.debug(
  77. "#### openid client: %s (%s)" % (client.auth_redirect_uri, redirect_uri)
  78. )
  79. if client.auth_redirect_uri != redirect_uri:
  80. raise OAuthException(
  81. "redirect_uri param doesn't match the pre-configured redirect URI",
  82. OAuthException.INVALID_GRANT,
  83. )
  84. return redirect_uri
  85. def __validate_client_secret(self, client, req, **query):
  86. if "client_secret" not in query or query["client_secret"] != client.secret:
  87. _logger.debug(
  88. "#### openid client: %s (%s)" % (query["client_secret"], client.secret)
  89. )
  90. raise OAuthException(
  91. "client_secret param is not valid", OAuthException.INVALID_CLIENT,
  92. )
  93. @http.route("/.well-known/openid-configuration", auth="public", type="http")
  94. def metadata(self, req, **query):
  95. base_url = http.request.httprequest.host_url
  96. data = {
  97. "issuer": base_url,
  98. "authorization_endpoint": base_url + "oauth/authorize",
  99. "token_endpoint": base_url + "oauth/token",
  100. "userinfo_endpoint": base_url + "oauth/userinfo",
  101. "jwks_uri": base_url + "oauth/jwks",
  102. "scopes_supported": ["openid"],
  103. "response_types_supported": RESPONSE_TYPES_SUPPORTED,
  104. "grant_types_supported": [
  105. "authorization_code",
  106. "implicit",
  107. "password",
  108. "client_credentials",
  109. ],
  110. "subject_types_supported": ["public"],
  111. "id_token_signing_alg_values_supported": ["RS256"],
  112. "token_endpoint_auth_methods_supported": ["client_secret_post"],
  113. }
  114. return json.dumps(data)
  115. @http.route("/oauth/jwks", auth="public", type="http")
  116. def jwks(self, req, **query):
  117. keyset = jwk.JWKSet()
  118. keyset.add(self.__get_id_token_jwk(req))
  119. return keyset.export(private_keys=False)
  120. @resource("/oauth/userinfo", method="GET")
  121. def userinfo(self, req, **query):
  122. user = req.env.user
  123. values = {
  124. "sub": str(user.id),
  125. # Needed in case the client is another Odoo instance
  126. "user_id": str(user.id),
  127. "name": user.name,
  128. }
  129. if user.email:
  130. values["email"] = user.email
  131. _logger.debug("#### OPENID (3): %s" % values)
  132. return values
  133. @resource("/oauth/clientinfo", method="GET", auth="client")
  134. def clientinfo(self, req, **query):
  135. client = req.env["galicea_openid_connect.client"].browse(
  136. req.context["client_id"]
  137. )
  138. return {"name": client.name}
  139. @http.route("/oauth/authorize", auth="public", type="http", csrf=False)
  140. def authorize(self, req, **query):
  141. # First, validate client_id and redirect_uri params.
  142. _logger.debug("#### OPENID (auth)")
  143. try:
  144. client = self.__validate_client(req, **query)
  145. redirect_uri = self.__validate_redirect_uri(client, req, **query)
  146. except OAuthException as e:
  147. # If those are not valid, we must not redirect back to the client
  148. # - instead, we display a message to the user
  149. _logger.debug("#### OPENID (4): %s" % e)
  150. return req.render("galicea_openid_connect.error", {"exception": e})
  151. scopes = query["scope"].split(" ") if query.get("scope") else []
  152. is_openid_request = "openid" in scopes
  153. # state, if present, is just mirrored back to the client
  154. response_params = {}
  155. if "state" in query:
  156. response_params["state"] = query["state"]
  157. response_mode = query.get("response_mode")
  158. try:
  159. if response_mode and response_mode not in ["query", "fragment"]:
  160. _logger.debug("#### OPENID (auth 1)")
  161. response_mode = None
  162. raise OAuthException(
  163. "The only supported response_modes are 'query' and 'fragment'",
  164. OAuthException.INVALID_REQUEST,
  165. )
  166. if "response_type" not in query:
  167. _logger.debug("#### OPENID (auth 2)")
  168. raise OAuthException(
  169. "response_type param is missing", OAuthException.INVALID_REQUEST,
  170. )
  171. response_type = query["response_type"]
  172. if response_type not in RESPONSE_TYPES_SUPPORTED:
  173. _logger.debug("#### OPENID (auth 3)")
  174. raise OAuthException(
  175. "The only supported response_types are: {}".format(
  176. ", ".join(RESPONSE_TYPES_SUPPORTED)
  177. ),
  178. OAuthException.UNSUPPORTED_RESPONSE_TYPE,
  179. )
  180. except OAuthException as e:
  181. _logger.debug("#### OPENID (5): %s" % e)
  182. response_params["error"] = e.type
  183. response_params["error_description"] = e
  184. return self.__redirect(
  185. redirect_uri, response_params, response_mode or "query"
  186. )
  187. _logger.debug("#### OPENID (auth 4)")
  188. if not response_mode:
  189. response_mode = "query" if response_type == "code" else "fragment"
  190. user = req.env.user
  191. # In case user is not logged in, we redirect to the login page and come back
  192. needs_login = user.login == "public"
  193. # Also if they didn't authenticate recently enough
  194. if (
  195. "max_age" in query
  196. and http.request.session.get("auth_time", 0) + int(query["max_age"])
  197. < time.time()
  198. ):
  199. needs_login = True
  200. if needs_login:
  201. params = {
  202. "force_auth_and_redirect": "/oauth/authorize?{}".format(
  203. werkzeug.url_encode(query)
  204. )
  205. }
  206. _logger.debug("#### OPENID (auth 4.2): %s" % params)
  207. return self.__redirect("/web/login", params, "query")
  208. response_types = response_type.split()
  209. extra_claims = {
  210. "sid": http.request.httprequest.session.sid,
  211. }
  212. if "nonce" in query:
  213. extra_claims["nonce"] = query["nonce"]
  214. if "code" in response_types:
  215. # Generate code that can be used by the client server to retrieve
  216. # the token. It's set to be valid for 60 seconds only.
  217. # TODO: The spec says the code should be single-use. We're not enforcing
  218. # that here.
  219. payload = {
  220. "redirect_uri": redirect_uri,
  221. "client_id": client.client_id,
  222. "user_id": user.id,
  223. "scopes": scopes,
  224. "exp": int(time.time()) + 60,
  225. }
  226. payload.update(extra_claims)
  227. key = self.__get_authorization_code_jwk(req)
  228. response_params["code"] = jwt_encode(payload, key)
  229. if "token" in response_types:
  230. access_token = (
  231. req.env["galicea_openid_connect.access_token"]
  232. .sudo()
  233. .retrieve_or_create(user.id, client.id)
  234. .token
  235. )
  236. response_params["access_token"] = access_token
  237. response_params["token_type"] = "bearer"
  238. digest = hashes.Hash(hashes.SHA256(), backend=default_backend())
  239. digest.update(access_token.encode("ascii"))
  240. at_hash = digest.finalize()
  241. extra_claims["at_hash"] = base64.urlsafe_b64encode(at_hash[:16]).strip("=")
  242. if "id_token" in response_types:
  243. response_params["id_token"] = self.__create_id_token(
  244. req, user.id, client, extra_claims
  245. )
  246. _logger.debug(
  247. "#### OPENID (6): %s, %s, %s"
  248. % (redirect_uri, response_params, response_mode)
  249. )
  250. _logger.debug(
  251. "#### OPENID (6.1): %s"
  252. % self.__redirect(redirect_uri, response_params, response_mode)
  253. )
  254. return self.__redirect(redirect_uri, response_params, response_mode)
  255. @http.route(
  256. "/oauth/token",
  257. auth="public",
  258. type="http",
  259. methods=["POST", "OPTIONS"],
  260. csrf=False,
  261. )
  262. def token(self, req, **query):
  263. cors_headers = {
  264. "Access-Control-Allow-Origin": "*",
  265. "Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept, X-Debug-Mode, Authorization",
  266. "Access-Control-Max-Age": 60 * 60 * 24,
  267. }
  268. if req.httprequest.method == "OPTIONS":
  269. return http.Response(status=200, headers=cors_headers)
  270. try:
  271. _logger.debug("#### OPENID (7): %s" % query)
  272. if "grant_type" not in query:
  273. raise OAuthException(
  274. "grant_type param is missing", OAuthException.INVALID_REQUEST,
  275. )
  276. if query["grant_type"] == "authorization_code":
  277. return json.dumps(
  278. self.__handle_grant_type_authorization_code(req, **query)
  279. )
  280. elif query["grant_type"] == "client_credentials":
  281. return json.dumps(
  282. self.__handle_grant_type_client_credentials(req, **query)
  283. )
  284. elif query["grant_type"] == "password":
  285. return werkzeug.Response(
  286. response=json.dumps(
  287. self.__handle_grant_type_password(req, **query)
  288. ),
  289. headers=cors_headers,
  290. )
  291. else:
  292. raise OAuthException(
  293. "Unsupported grant_type param: '{}'".format(query["grant_type"]),
  294. OAuthException.UNSUPPORTED_GRANT_TYPE,
  295. )
  296. except OAuthException as e:
  297. body = json.dumps({"error": e.type, "error_description": e})
  298. return werkzeug.Response(response=body, status=400, headers=cors_headers)
  299. def __handle_grant_type_authorization_code(self, req, **query):
  300. client = self.__validate_client(req, **query)
  301. redirect_uri = self.__validate_redirect_uri(client, req, **query)
  302. self.__validate_client_secret(client, req, **query)
  303. if "code" not in query:
  304. raise OAuthException(
  305. "code param is missing", OAuthException.INVALID_GRANT,
  306. )
  307. try:
  308. payload = jwt_decode(query["code"], self.__get_authorization_code_jwk(req))
  309. _logger.debug("#### OPENID (8): %s" % payload)
  310. except jwt.JWTExpired:
  311. _logger.debug("#### OPENID (9): %s" % OAuthException.INVALID_GRANT)
  312. raise OAuthException(
  313. "Code expired", OAuthException.INVALID_GRANT,
  314. )
  315. except ValueError:
  316. _logger.debug("#### OPENID (10): %s" % OAuthException.INVALID_GRANT)
  317. raise OAuthException(
  318. "code malformed", OAuthException.INVALID_GRANT,
  319. )
  320. if payload["client_id"] != client.client_id:
  321. _logger.debug("#### OPENID (11): %s" % OAuthException.INVALID_GRANT)
  322. raise OAuthException(
  323. "client_id doesn't match the authorization request",
  324. OAuthException.INVALID_GRANT,
  325. )
  326. if payload["redirect_uri"] != redirect_uri:
  327. _logger.debug("#### OPENID (12): %s" % OAuthException.INVALID_GRANT)
  328. raise OAuthException(
  329. "redirect_uri doesn't match the authorization request",
  330. OAuthException.INVALID_GRANT,
  331. )
  332. # Retrieve/generate access token. We currently only store one per user/client
  333. token = (
  334. req.env["galicea_openid_connect.access_token"]
  335. .sudo()
  336. .retrieve_or_create(payload["user_id"], client.id)
  337. )
  338. response = {"access_token": token.token, "token_type": "bearer"}
  339. if "openid" in payload["scopes"]:
  340. extra_claims = {
  341. name: payload[name] for name in payload if name in ["sid", "nonce"]
  342. }
  343. response["id_token"] = self.__create_id_token(
  344. req, payload["user_id"], client, extra_claims
  345. )
  346. _logger.debug("#### OPENID (12): %s" % response)
  347. return response
  348. def __handle_grant_type_password(self, req, **query):
  349. client = self.__validate_client(req, **query)
  350. if not client.allow_password_grant:
  351. raise OAuthException(
  352. "This client is not allowed to perform password flow",
  353. OAuthException.UNSUPPORTED_GRANT_TYPE,
  354. )
  355. for param in ["username", "password"]:
  356. if param not in query:
  357. raise OAuthException(
  358. "{} is required".format(param), OAuthException.INVALID_REQUEST
  359. )
  360. user_id = req.env["res.users"].authenticate(
  361. req.env.cr.dbname, query["username"], query["password"], None
  362. )
  363. if not user_id:
  364. raise OAuthException(
  365. "Invalid username or password", OAuthException.INVALID_REQUEST
  366. )
  367. scopes = query["scope"].split(" ") if query.get("scope") else []
  368. # Retrieve/generate access token. We currently only store one per user/client
  369. token = (
  370. req.env["galicea_openid_connect.access_token"]
  371. .sudo()
  372. .retrieve_or_create(user_id, client.id)
  373. )
  374. response = {"access_token": token.token, "token_type": "bearer"}
  375. if "openid" in scopes:
  376. response["id_token"] = self.__create_id_token(req, user_id, client, {})
  377. return response
  378. def __handle_grant_type_client_credentials(self, req, **query):
  379. client = self.__validate_client(req, **query)
  380. self.__validate_client_secret(client, req, **query)
  381. token = (
  382. req.env["galicea_openid_connect.client_access_token"]
  383. .sudo()
  384. .retrieve_or_create(client.id)
  385. )
  386. return {"access_token": token.token, "token_type": "bearer"}
  387. def __create_id_token(self, req, user_id, client, extra_claims):
  388. claims = {
  389. "iss": http.request.httprequest.host_url,
  390. "sub": str(user_id),
  391. "aud": client.client_id,
  392. "iat": int(time.time()),
  393. "exp": int(time.time()) + 15 * 60,
  394. }
  395. auth_time = extra_claims.get("sid") and http.root.session_store.get(
  396. extra_claims["sid"]
  397. ).get("auth_time")
  398. if auth_time:
  399. claims["auth_time"] = auth_time
  400. if "nonce" in extra_claims:
  401. claims["nonce"] = extra_claims["nonce"]
  402. if "at_hash" in extra_claims:
  403. claims["at_hash"] = extra_claims["at_hash"]
  404. key = self.__get_id_token_jwk(req)
  405. return jwt_encode(claims, key)
  406. def __redirect(self, url, params, response_mode):
  407. location = "{}{}{}".format(
  408. url, "?" if response_mode == "query" else "#", werkzeug.url_encode(params)
  409. )
  410. return werkzeug.Response(
  411. headers={"Location": location}, response=None, status=302,
  412. )