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.

439 lines
16 KiB

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