diff options
author | Lars Wirzenius <liw@liw.fi> | 2018-08-04 11:33:39 +0300 |
---|---|---|
committer | Lars Wirzenius <liw@liw.fi> | 2018-08-05 12:10:42 +0300 |
commit | 5416c57cd286ab614129a398fe4d2da681ecc8f4 (patch) | |
tree | 13c3c7a7c8fa827286cd7abc77e198122f0794d0 /qvisqve | |
parent | 99ee63f8247a7c89ca1180838db1a4974812ed23 (diff) | |
download | qvisqve-5416c57cd286ab614129a398fe4d2da681ecc8f4.tar.gz |
Add: OIDC authorization code flow
Diffstat (limited to 'qvisqve')
-rw-r--r-- | qvisqve/__init__.py | 7 | ||||
-rw-r--r-- | qvisqve/api.py | 7 | ||||
-rw-r--r-- | qvisqve/auth_router.py | 108 | ||||
-rw-r--r-- | qvisqve/authz_attempt.py | 120 | ||||
-rw-r--r-- | qvisqve/authz_attempt_tests.py | 115 | ||||
-rw-r--r-- | qvisqve/token.py | 4 | ||||
-rw-r--r-- | qvisqve/token_router.py | 36 |
7 files changed, 372 insertions, 25 deletions
diff --git a/qvisqve/__init__.py b/qvisqve/__init__.py index 0627c7b..a596298 100644 --- a/qvisqve/__init__.py +++ b/qvisqve/__init__.py @@ -52,3 +52,10 @@ from .token_router import TokenRouter from .api import API from .app import create_app + +from .noncegen import NonceGenerator +from .authz_attempt import ( + AuthorizationAttempt, + AuthorizationAttemptError, + AuthorizationAttempts, +) diff --git a/qvisqve/api.py b/qvisqve/api.py index b63bdb6..a08dd73 100644 --- a/qvisqve/api.py +++ b/qvisqve/api.py @@ -23,6 +23,7 @@ class API: self._config = config self._rs = None self._routes = None + self._attempts = qvisqve.AuthorizationAttempts() def find_missing_route(self, path): qvisqve.log.log('info', msg_text='find_missing_route', path=path) @@ -35,10 +36,12 @@ class API: qvisqve.VersionRouter(), qvisqve.ManagementRouter(storedir, baseurl), qvisqve.TokenRouter( - self._create_token_generator(), self._get_clients()), + self._create_token_generator(), self._get_clients(), + self._attempts), qvisqve.LoginRouter(), qvisqve.AuthRouter( - self._get_applications(), self._get_users()), + self._get_applications(), self._get_users(), + self._attempts), ] self._routes = [] diff --git a/qvisqve/auth_router.py b/qvisqve/auth_router.py index a91661c..097b5e7 100644 --- a/qvisqve/auth_router.py +++ b/qvisqve/auth_router.py @@ -17,54 +17,134 @@ import urllib.parse +import bottle + + import qvisqve +login_form = ''' +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <meta name="description" content="Qvisqve Login"> + <meta name="author" content="Qvarnlabs Ltd"> + <title>Qvisqve Login</title> + </head> + <body> + <form action="/auth" method="POST"> + <input name="attempt_id" value="{{attempt_id}}" type="hidden" /> + User name: <input name="username" type="text" /> + <br /> + Password: <input name="password" type="password" /> + <br /> + <input type="submit" value="Login" /> + </form> + </body> +</html> +''' + + class AuthRouter(qvisqve.Router): - def __init__(self, apps, users): + def __init__(self, apps, users, authz_attempts): super().__init__() self._apps = apps self._users = users + self._attempts = authz_attempts def get_routes(self): return [ { + 'method': 'GET', + 'path': '/auth', + 'callback': self._start_authz_code_flow, + 'needs-authorization': False, + }, + { 'method': 'POST', 'path': '/auth', - 'callback': self._auth, + 'callback': self._check_user_creds, 'needs-authorization': False, }, ] - def _auth(self, content_type, body, *args, **kwargs): + def _start_authz_code_flow(self, content_type, body, *args, **kwargs): + qvisqve.log.log( + 'trace', msg_text='_start_authz_code_flow', args=args, + kwargs=kwargs, content_type=content_type, body=body) + path = kwargs['raw_uri_path'] + if '?' not in path: + return qvisqve.bad_request_response('Not like that') + + path, qs = path.split('?', 1) + params = urllib.parse.parse_qs(qs) + cleaned = self._cleanup_params(params) + qvisqve.log.log( + 'trace', msg_text='params', path=path, qs=qs, params=params, + cleaned=cleaned) + + aa = self._attempts.create_attempt(cleaned) + form = bottle.template(login_form, attempt_id=aa.get_attempt_id()) + headers = { + 'Content-Type': 'text/html; charset=utf-8', + } + return qvisqve.ok_response(form, headers=headers) + + def _cleanup_params(self, params): + return { + name: params[name][-1] + for name in params + } + + def _check_user_creds(self, content_type, body, *args, **kwargs): + qvisqve.log.log( + 'trace', msg_text='_check_user_creds', args=args, + kwargs=kwargs, content_type=content_type, body=body) + if content_type != 'application/x-www-form-urlencoded': return qvisqve.bad_request_response('Wrong content type') params = self._get_form_params(body) username = self._get_param(params, 'username') password = self._get_param(params, 'password') + attempt_id = self._get_param(params, 'attempt_id') + qvisqve.log.log( + 'trace', msg_text='extracted form parameters', params=params, + username=username, password=password, attempt_id=attempt_id) + if None in (username, password, attempt_id): + return qvisqve.unauthorized_response('Access denied') + if not self._users.is_valid_secret(username, password): return qvisqve.unauthorized_response('Access denied') - # TODO: - # - perform actual auth - # - create and store auth code - # - use callback url provided in request + aa = self._attempts.find_by_id(attempt_id) + if aa is None: + return qvisqve.unauthorized_response('Access denied') - # FIXME use real app name here - callbacks = self._apps.get_callbacks('facade') - callback_url = callbacks[0] + aa.set_subject_id(username) - params = urllib.parse.urlencode({'code': 123}) - url = '{}?{}'.format(callback_url, params) + gen = qvisqve.NonceGenerator() + code = gen.create_nonce() + aa.set_authorization_code(code) - qvisqve.log.log('xxx', msg_text='Returning redirect', url=url) + params = { + 'code': code, + } + url = '{}?{}'.format( + aa.get_redirect_uri(), + urllib.parse.urlencode(params) + ) + qvisqve.log.log('trace', msg_text='Returning redirect', url=url) return qvisqve.found_response('Redirect to callback url', url) def _get_param(self, params, name): - return params[name][0] + values = params.get(name) + if not isinstance(values, list) or len(values) < 1: + return None + return values[0] def _get_form_params(self, body): body = body.decode('UTF-8') diff --git a/qvisqve/authz_attempt.py b/qvisqve/authz_attempt.py new file mode 100644 index 0000000..229c802 --- /dev/null +++ b/qvisqve/authz_attempt.py @@ -0,0 +1,120 @@ +# Copyright (C) 2018 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import qvisqve + + +class AuthorizationAttempt: + + def __init__(self): + self._attempt_id = None + self._client_id = None + self._subject_id = None + self._state = None + self._redirect_uri = None + self._scope = None + self._authorization_code = None + + def set_client_id(self, client_id): + self._client_id = client_id + + def get_client_id(self): + return self._client_id + + def set_subject_id(self, subject_id): + self._subject_id = subject_id + + def get_subject_id(self): + return self._subject_id + + def set_state(self, state): + self._state = state + + def get_state(self): + return self._state + + def set_redirect_uri(self, uri): + self._redirect_uri = uri + + def get_redirect_uri(self): + return self._redirect_uri + + def set_scope(self, scope): + self._scope = scope + + def get_scope(self): + return self._scope + + def set_attempt_id(self, attempt_id): + required = [ + '_client_id', + '_state', + '_redirect_uri', + '_scope', + ] + for attr in required: + if getattr(self, attr, None) is None: + raise AuthorizationAttemptError() + + self._attempt_id = attempt_id + + def get_attempt_id(self): + return self._attempt_id + + def set_authorization_code(self, authorization_code): + self._authorization_code = authorization_code + + def get_authorization_code(self): + return self._authorization_code + + +class AuthorizationAttemptError(Exception): + + pass + + +class AuthorizationAttempts: + + def __init__(self): + self._attempts = [] + + def create_attempt(self, urlparams): + gen = qvisqve.NonceGenerator() + attempt_id = gen.create_nonce() + + aa = AuthorizationAttempt() + + aa.set_client_id(urlparams['client_id']) + aa.set_state(urlparams['state']) + aa.set_redirect_uri(urlparams['redirect_uri']) + aa.set_scope(urlparams['scope']) + + aa.set_attempt_id(attempt_id) + + self._attempts.append(aa) + return aa + + def find_by_id(self, attempt_id): + for aa in self._attempts: + if aa.get_attempt_id() == attempt_id: + return aa + return None + + def find_by_code(self, code): + for aa in self._attempts: + if aa.get_authorization_code() == code: + return aa + return None diff --git a/qvisqve/authz_attempt_tests.py b/qvisqve/authz_attempt_tests.py new file mode 100644 index 0000000..d46660d --- /dev/null +++ b/qvisqve/authz_attempt_tests.py @@ -0,0 +1,115 @@ +# Copyright (C) 2018 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import unittest + +import qvisqve + + +class AuthorizationAttemptTests(unittest.TestCase): + + def test_raises_error_creating_attempt_id_before_all_fields_set(self): + gen = qvisqve.NonceGenerator() + attempt_id = gen.create_nonce() + + aa = qvisqve.AuthorizationAttempt() + subject_id = 'subject_id' + client_id = 'client_id' + state = 'state' + uri = 'https://facade/callback' + scope = 'scope' + + with self.assertRaises(qvisqve.AuthorizationAttemptError): + aa.set_attempt_id(attempt_id) + + aa.set_client_id(client_id) + with self.assertRaises(qvisqve.AuthorizationAttemptError): + aa.set_attempt_id(attempt_id) + + aa.set_state(state) + with self.assertRaises(qvisqve.AuthorizationAttemptError): + aa.set_attempt_id(attempt_id) + + aa.set_redirect_uri(uri) + with self.assertRaises(qvisqve.AuthorizationAttemptError): + aa.set_attempt_id(attempt_id) + + aa.set_scope(scope) + aa.set_subject_id(subject_id) + aa.set_attempt_id(attempt_id) + + self.assertEqual(aa.get_subject_id(), subject_id) + self.assertEqual(aa.get_client_id(), client_id) + self.assertEqual(aa.get_state(), state) + self.assertEqual(aa.get_redirect_uri(), uri) + self.assertEqual(aa.get_scope(), scope) + self.assertEqual(aa.get_attempt_id(), attempt_id) + + def test_has_not_authz_code_initially(self): + aa = qvisqve.AuthorizationAttempt() + self.assertEqual(aa.get_authorization_code(), None) + + def test_sets_authz_code(self): + aa = qvisqve.AuthorizationAttempt() + code = '12765' + aa.set_authorization_code(code) + self.assertEqual(aa.get_authorization_code(), code) + + +class AuthorizationAttemptsTests(unittest.TestCase): + + def setUp(self): + self.urlparams = { + 'scope': 'openid read', + 'client_id': 'client_id', + 'state': 'RANDOM', + 'redirect_uri': 'https://facade', + } + self.aas = qvisqve.AuthorizationAttempts() + + def test_creates_attempt(self): + aa = self.aas.create_attempt(self.urlparams) + attempt_id = aa.get_attempt_id() + self.assertNotEqual(attempt_id, None) + + self.assertEqual(aa.get_scope(), self.urlparams['scope']) + self.assertEqual(aa.get_client_id(), self.urlparams['client_id']) + self.assertEqual(aa.get_state(), self.urlparams['state']) + self.assertEqual(aa.get_redirect_uri(), self.urlparams['redirect_uri']) + + def test_finds_by_id(self): + aa = self.aas.create_attempt(self.urlparams) + attempt_id = aa.get_attempt_id() + self.assertEqual(aa, self.aas.find_by_id(attempt_id)) + + def test_returns_none_when_finding_by_a_non_existent_id(self): + aa = self.aas.create_attempt(self.urlparams) + attempt_id = aa.get_attempt_id() + nonexistent = attempt_id * 2 + self.assertEqual(self.aas.find_by_id(nonexistent), None) + + def test_finds_by_code(self): + aa = self.aas.create_attempt(self.urlparams) + code = 'xxx' + aa.set_authorization_code(code) + self.assertEqual(aa, self.aas.find_by_code(code)) + + def test_returns_none_when_finding_by_a_non_existent_code(self): + aa = self.aas.create_attempt(self.urlparams) + code = 'xxx' + aa.set_authorization_code(code) + nonexistent = 'yyy' + self.assertEqual(self.aas.find_by_code(nonexistent), None) diff --git a/qvisqve/token.py b/qvisqve/token.py index 3b343c2..edc62fd 100644 --- a/qvisqve/token.py +++ b/qvisqve/token.py @@ -50,7 +50,7 @@ class TokenGenerator: 'info', msg_text='Set signing key', key=self._key, orig_key=key, imported_key=imported_key) - def new_token(self, audience, scope): + def new_token(self, audience, scope, subject_id=None): assert self._issuer is not None assert self._lifetime is not None assert self._key is not None @@ -58,7 +58,7 @@ class TokenGenerator: now = time.time() claims = { 'iss': self._issuer, - 'sub': '', + 'sub': subject_id or '', 'aud': audience, 'exp': now + self._lifetime, 'scope': scope, diff --git a/qvisqve/token_router.py b/qvisqve/token_router.py index 911e899..c510b8b 100644 --- a/qvisqve/token_router.py +++ b/qvisqve/token_router.py @@ -26,10 +26,10 @@ import qvisqve_secrets class TokenRouter(qvisqve.Router): - def __init__(self, token_generator, clients): + def __init__(self, token_generator, clients, authz_attempts): qvisqve.log.log('debug', msg_text='TokenRouter init starts') super().__init__() - args = (clients, token_generator) + args = (clients, token_generator, authz_attempts) self._grants = { 'client_credentials': ClientCredentialsGrant(*args), 'authorization_code': AuthorizationCodeGrant(*args), @@ -75,9 +75,10 @@ class TokenRouter(qvisqve.Router): class Grant: - def __init__(self, clients, generator): + def __init__(self, clients, generator, authz_attempts): self._clients = clients self._generator = generator + self._attempts = authz_attempts class ClientCredentialsGrant(Grant): @@ -123,11 +124,32 @@ class ClientCredentialsGrant(Grant): class AuthorizationCodeGrant(Grant): def get_token(self, request, params): + client_id, client_secret = request.auth + if not self._clients.is_valid_secret(client_id, client_secret): + qvisqve.log.log('error', msg_text='Invalid client creds given') + return qvisqve.unauthorized_response('Access denied') + code = self._get_code(params) - # FIXME - if code is None or code != '123': - return qvisqve.unauthorized_response('Unauthorized') - empty_token = self._generator.new_token('', '') + if code is None: + qvisqve.log.log('error', msg_text='No code given') + return qvisqve.unauthorized_response('Access denied') + + aa = self._attempts.find_by_code(code) + if aa is None: + qvisqve.log.log('error', msg_text='Unknown code given', code=code) + return qvisqve.unauthorized_response('Access denied') + + subject_id = aa.get_subject_id() + scope = aa.get_scope() + allowed = self._clients.get_allowed_scopes(client_id) + scope = ' '.join( + s + for s in scope.split() + if s in allowed + ) + + empty_token = self._generator.new_token( + '', scope, subject_id=subject_id) return qvisqve.ok_response({ 'access_token': empty_token, 'token_type': 'Bearer', |