summaryrefslogtreecommitdiff
path: root/qvisqve
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2018-08-04 11:33:39 +0300
committerLars Wirzenius <liw@liw.fi>2018-08-05 12:10:42 +0300
commit5416c57cd286ab614129a398fe4d2da681ecc8f4 (patch)
tree13c3c7a7c8fa827286cd7abc77e198122f0794d0 /qvisqve
parent99ee63f8247a7c89ca1180838db1a4974812ed23 (diff)
downloadqvisqve-5416c57cd286ab614129a398fe4d2da681ecc8f4.tar.gz
Add: OIDC authorization code flow
Diffstat (limited to 'qvisqve')
-rw-r--r--qvisqve/__init__.py7
-rw-r--r--qvisqve/api.py7
-rw-r--r--qvisqve/auth_router.py108
-rw-r--r--qvisqve/authz_attempt.py120
-rw-r--r--qvisqve/authz_attempt_tests.py115
-rw-r--r--qvisqve/token.py4
-rw-r--r--qvisqve/token_router.py36
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',