diff options
author | Lars Wirzenius <liw@liw.fi> | 2018-07-13 15:21:14 +0300 |
---|---|---|
committer | Lars Wirzenius <liw@liw.fi> | 2018-07-13 15:21:14 +0300 |
commit | 2ee4809524a80e2223049e10b67d8d6e068d4285 (patch) | |
tree | 9da7a25863979c8f5554b98e464f96c4643b4aee | |
parent | 5e68c02a915ff50a9b8bc14b7f68c255be92cf21 (diff) | |
parent | da30b04924a2748e059a4fbc98eb6e6d0d0177f4 (diff) | |
download | qvisqve-2ee4809524a80e2223049e10b67d8d6e068d4285.tar.gz |
Merge: end-user login implementation
-rw-r--r-- | qvisqve/__init__.py | 3 | ||||
-rw-r--r-- | qvisqve/api.py | 2 | ||||
-rw-r--r-- | qvisqve/auth_router.py | 51 | ||||
-rw-r--r-- | qvisqve/login_router.py | 62 | ||||
-rw-r--r-- | qvisqve/responses.py | 9 | ||||
-rw-r--r-- | qvisqve/token_router.py | 75 | ||||
-rw-r--r-- | views/login.tpl | 18 | ||||
-rw-r--r-- | without-tests | 2 | ||||
-rw-r--r-- | yarns/300-end-user-auth.yarn | 6 | ||||
-rw-r--r-- | yarns/900-implements.yarn | 6 | ||||
-rw-r--r-- | yarns/900-local.yarn | 10 |
11 files changed, 216 insertions, 28 deletions
diff --git a/qvisqve/__init__.py b/qvisqve/__init__.py index a1d80f9..32032b6 100644 --- a/qvisqve/__init__.py +++ b/qvisqve/__init__.py @@ -19,12 +19,15 @@ from .responses import ( created_response, ok_response, unauthorized_response, + found_response, ) from .log_setup import setup_logging, log from .token import TokenGenerator from .router import Router from .version_router import VersionRouter +from .login_router import LoginRouter +from .auth_router import AuthRouter from .token_router import TokenRouter from .api import API diff --git a/qvisqve/api.py b/qvisqve/api.py index 2dee954..a6d71ef 100644 --- a/qvisqve/api.py +++ b/qvisqve/api.py @@ -27,6 +27,8 @@ class API: routers = [ qvisqve.VersionRouter(), + qvisqve.LoginRouter(), + qvisqve.AuthRouter(self._config.get('callback_url', 'localhost')), qvisqve.TokenRouter( self._create_token_generator(), self._get_clients()), ] diff --git a/qvisqve/auth_router.py b/qvisqve/auth_router.py new file mode 100644 index 0000000..d4f3ca6 --- /dev/null +++ b/qvisqve/auth_router.py @@ -0,0 +1,51 @@ +# Copyright (C) 2018 Ivan Dolgov +# +# 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 urllib.parse + + +import qvisqve + + +class AuthRouter(qvisqve.Router): + + def __init__(self, callback_url): + super().__init__() + self._callback_url = callback_url + + def get_routes(self): + return [ + { + 'method': 'POST', + 'path': '/auth', + 'callback': self._auth, + 'needs-authorization': False, + }, + ] + + def _auth(self, content_type, body, *args, **kwargs): + if content_type != 'application/x-www-form-urlencoded': + return qvisqve.bad_request_response('Wrong content type') + + # TODO: + # - perform actual auth + # - create and store auth code + # - use callback url provided in request + + params = urllib.parse.urlencode({'code': 123}) + url = '{}?{}'.format(self._callback_url, params) + + return qvisqve.found_response('Redirect to callback url', url) diff --git a/qvisqve/login_router.py b/qvisqve/login_router.py new file mode 100644 index 0000000..ef460b4 --- /dev/null +++ b/qvisqve/login_router.py @@ -0,0 +1,62 @@ +# Copyright (C) 2018 Ivan Dolgov +# +# 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 bottle + + +import qvisqve + + +login_template = '''\ +<!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"> + User name: <input name="username" type="text" /> + <br /> + Password: <input name="password" type="password" /> + <br /> + <input type="submit" value="Login" /> + </form> + </body> +</html> +''' + + +class LoginRouter(qvisqve.Router): + + def get_routes(self): + return [ + { + 'method': 'GET', + 'path': '/login', + 'callback': self._login, + 'needs-authorization': False, + }, + ] + + def _login(self, *args, **kwargs): + headers = { + 'Content-Type': 'text/html; charset=utf-8', + } + template = bottle.template(login_template) + return qvisqve.ok_response(template, headers=headers) diff --git a/qvisqve/responses.py b/qvisqve/responses.py index 281ec43..d36094e 100644 --- a/qvisqve/responses.py +++ b/qvisqve/responses.py @@ -57,3 +57,12 @@ def unauthorized_response(body): 'Content-Type': 'text/plain', } return response(apifw.HTTP_UNAUTHORIZED, body, headers) + + +def found_response(body, location): + headers = { + 'Content-Type': 'text/plain', + 'Location': location, + } + # TODO: use apifw.FOUND_CREATED when available + return response(302, body, headers) diff --git a/qvisqve/token_router.py b/qvisqve/token_router.py index 9acc924..b5f30fe 100644 --- a/qvisqve/token_router.py +++ b/qvisqve/token_router.py @@ -28,8 +28,11 @@ class TokenRouter(qvisqve.Router): def __init__(self, token_generator, clients): super().__init__() - self._generator = token_generator - self._clients = Clients(clients) + args = (Clients(clients), token_generator) + self._grants = { + 'client_credentials': ClientCredentialsGrant(*args), + 'authorization_code': AuthorizationCodeGrant(*args), + } def get_routes(self): return [ @@ -47,15 +50,40 @@ class TokenRouter(qvisqve.Router): if content_type != 'application/x-www-form-urlencoded': return qvisqve.bad_request_response('Wrong content type') - client_id, client_secret = bottle.request.auth - if not self._clients.is_correct_secret(client_id, client_secret): - return qvisqve.unauthorized_response('Unauthorized') - params = self._get_form_params(body) - grant_type = self._get_grant_type(params) - if grant_type != 'client_credentials': + grant = self._get_grant(grant_type) + if grant is None: return qvisqve.bad_request_response('Wrong grant type') + return grant.get_token(bottle.request, params) + + def _get_form_params(self, body): + body = body.decode('UTF-8') + return urllib.parse.parse_qs(body) + + def _get_grant_type(self, params): + grant_type = params.get('grant_type') + if len(grant_type) == 1: + return grant_type[0] + return None + + def _get_grant(self, grant_type): + return self._grants.get(grant_type) + + +class Grant: + + def __init__(self, clients, generator): + self._clients = clients + self._generator = generator + + +class ClientCredentialsGrant(Grant): + + def get_token(self, request, params): + client_id, client_secret = request.auth + if not self._clients.is_correct_secret(client_id, client_secret): + return qvisqve.unauthorized_response('Unauthorized') scope = self._get_scope(params) if scope is None: @@ -75,16 +103,6 @@ class TokenRouter(qvisqve.Router): 'scope': scope, }) - def _get_form_params(self, body): - body = body.decode('UTF-8') - return urllib.parse.parse_qs(body) - - def _get_grant_type(self, params): - grant_type = params.get('grant_type') - if len(grant_type) == 1: - return grant_type[0] - return None - def _get_scope(self, params): scope = params.get('scope', []) if len(scope) > 1: @@ -94,6 +112,27 @@ class TokenRouter(qvisqve.Router): return '' +class AuthorizationCodeGrant(Grant): + + def get_token(self, request, params): + code = self._get_code(params) + # FIXME + if code is None or code != '123': + return qvisqve.unauthorized_response('Unauthorized') + empty_token = self._generator.new_token('', '') + return qvisqve.ok_response({ + 'access_token': empty_token, + 'token_type': 'bearer', + 'scope': '', + }) + + def _get_code(self, params): + code = params.get('code') + if len(code) == 1: + return code[0] + return None + + class Clients: def __init__(self, clients): diff --git a/views/login.tpl b/views/login.tpl new file mode 100644 index 0000000..dae2c4c --- /dev/null +++ b/views/login.tpl @@ -0,0 +1,18 @@ +<!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"> + User name: <input name="username" type="text" /> + <br /> + Password: <input name="password" type="password" /> + <br /> + <input type="submit" value="Login" /> + </form> + </body> +</html> diff --git a/without-tests b/without-tests index 45ab43b..1fc97eb 100644 --- a/without-tests +++ b/without-tests @@ -3,8 +3,10 @@ doc/build.py qvisqve/__init__.py qvisqve/api.py qvisqve/app.py +qvisqve/auth_router.py qvisqve/backend.py qvisqve/log_setup.py +qvisqve/login_router.py qvisqve/responses.py qvisqve/router.py qvisqve/token.py diff --git a/yarns/300-end-user-auth.yarn b/yarns/300-end-user-auth.yarn index 2b717c8..8a4d412 100644 --- a/yarns/300-end-user-auth.yarn +++ b/yarns/300-end-user-auth.yarn @@ -19,18 +19,18 @@ FIXME: Explain the login process here, with sequence diagram. User goes to the login URL and gets a login page. - WHEN browser requests GET https://qvisqve/login + WHEN browser requests GET /login THEN HTTP status code is 200 OK AND Content-Type is text/html AND body has an HTML form with field username AND body has an HTML form with field password - WHEN browser requests POST https://qvisqve/auth, with form values + WHEN browser requests POST /qvisqve/auth, with form values ... username=tomjon and password=hunter2 THEN HTTP status code is 302 Found AND Location header is https://facade/callback?code=123 - WHEN facade requests POST https://qvisqve/token, with + WHEN facade requests POST /qvisqve/token, with ... form values grant_type=authorization_code and code=123 THEN HTTP status code is 200 OK AND Content-Type is application/json diff --git a/yarns/900-implements.yarn b/yarns/900-implements.yarn index 53f675a..e21d8d5 100644 --- a/yarns/900-implements.yarn +++ b/yarns/900-implements.yarn @@ -84,8 +84,8 @@ This chapter shows the scenario step implementations. IMPLEMENTS WHEN browser requests GET (\S+) # FIXME: This is a dummy implemantation, does not do anything real. - V['status_code'] = 200 - V['headers'] = {'Content-Type': 'text/html'} + path = get_next_match() + V['status_code'], V['headers'], V['body'] = get(V['API_URL'] + path, {}) IMPLEMENTS WHEN browser requests POST (\S+)$ # FIXME: This is a dummy implemantation, does not do anything real. @@ -178,7 +178,7 @@ This chapter shows the scenario step implementations. IMPLEMENTS THEN Content-Type is (\S+) wanted = get_next_match() headers = V['headers'] - assertEqual(headers['Content-Type'], wanted) + assertEqual(headers['Content-Type'][:len(wanted)], wanted) IMPLEMENTS THEN Location header is (\S+) # FIXME: This is a dummy implemantation, does not do anything real. diff --git a/yarns/900-local.yarn b/yarns/900-local.yarn index 14c3937..ddce8f8 100644 --- a/yarns/900-local.yarn +++ b/yarns/900-local.yarn @@ -38,12 +38,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. V['lifetime'] = int(get_next_match()) IMPLEMENTS GIVEN Qvisqve configuration has user account (\S+) with password (\S+) - # FIXME: This is a dummy implemantation, does not do anything real. - pass + username = get_next_match() + password = get_next_match() + V['users'] = { username: password } IMPLEMENTS GIVEN Qvisqve configuration has application (\S+) with callback url (\S+) - # FIXME: This is a dummy implemantation, does not do anything real. - pass + app = get_next_match() + callback = get_next_match() + V['applications'] = { app: callback } ## Authentication setup |