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.

429 lines
16 KiB

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