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.

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