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.

421 lines
16 KiB

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