diff --git a/.travis.yml b/.travis.yml index 84321a6..0f0d5d4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,8 +9,7 @@ install: - pip install coveralls script: -- pyflakes aiohttp_security tests -- pep8 aiohttp_security tests +- flake8 aiohttp_security tests - coverage run --source=aiohttp_security setup.py test after_success: diff --git a/README.rst b/README.rst index fa83e86..733574a 100644 --- a/README.rst +++ b/README.rst @@ -9,6 +9,14 @@ __ aiohttp_web_ Usage ----- +To install type ``pip install aiohttp_security``. +Launch ``make doc`` and see examples or look under **demo** directory for a +sample project. + +Develop +------- + +``pip install -r requirements-dev`` License diff --git a/aiohttp_security/cookies_identity.py b/aiohttp_security/cookies_identity.py index 72ead90..807af34 100644 --- a/aiohttp_security/cookies_identity.py +++ b/aiohttp_security/cookies_identity.py @@ -1,4 +1,4 @@ -"""Identity polocy for storing info directly into HTTP cookie. +"""Identity policy for storing info directly into HTTP cookie. Use mostly for demonstration purposes, SessionIdentityPolicy is much more handy. diff --git a/aiohttp_security/session_identity.py b/aiohttp_security/session_identity.py index ea2beac..5fdf55a 100644 --- a/aiohttp_security/session_identity.py +++ b/aiohttp_security/session_identity.py @@ -1,7 +1,7 @@ """Identity policy for storing info into aiohttp_session session. aiohttp_session.setup() should be called on application initialization -to conffigure aiohttp_session properly. +to configure aiohttp_session properly. """ import asyncio diff --git a/demo/db.py b/demo/db.py index 0fb875a..c315988 100644 --- a/demo/db.py +++ b/demo/db.py @@ -9,7 +9,6 @@ users = sa.Table( sa.Column('id', sa.Integer, nullable=False), sa.Column('login', sa.String(256), nullable=False), sa.Column('passwd', sa.String(256), nullable=False), - sa.Column('salt', sa.String(256), nullable=False), sa.Column('is_superuser', sa.Boolean, nullable=False, server_default='FALSE'), sa.Column('disabled', sa.Boolean, nullable=False, diff --git a/demo/db_auth.py b/demo/db_auth.py index f58f47e..bb9897c 100644 --- a/demo/db_auth.py +++ b/demo/db_auth.py @@ -1,7 +1,9 @@ import asyncio + import sqlalchemy as sa from aiohttp_security.abc import AbstractAuthorizationPolicy +from passlib.hash import sha256_crypt from . import db @@ -11,11 +13,11 @@ class DBAuthorizationPolicy(AbstractAuthorizationPolicy): self.dbengine = dbengine @asyncio.coroutine - def authorized_user_id(self, identity): + def authorized_userid(self, identity): with (yield from self.dbengine) as conn: - where = [db.users.c.login == identity, - not db.users.c.disabled] - query = db.users.count().where(sa.and_(*where)) + where = sa.and_(db.users.c.login == identity, + sa.not_(db.users.c.disabled)) + query = db.users.count().where(where) ret = yield from conn.scalar(query) if ret: return identity @@ -24,12 +26,42 @@ class DBAuthorizationPolicy(AbstractAuthorizationPolicy): @asyncio.coroutine def permits(self, identity, permission, context=None): + if identity is None: + return False + with (yield from self.dbengine) as conn: - where = [db.users.c.login == identity, - not db.users.c.disabled] - record = self.data.get(identity) - if record is not None: - # TODO: implement actual permission checker - if permission in record: - return True - return False + where = sa.and_(db.users.c.login == identity, + sa.not_(db.users.c.disabled)) + query = db.users.select().where(where) + ret = yield from conn.execute(query) + user = yield from ret.fetchone() + if user is not None: + user_id = user[0] + is_superuser = user[3] + if is_superuser: + return True + + where = db.permissions.c.user_id == user_id + query = db.permissions.select().where(where) + ret = yield from conn.execute(query) + result = yield from ret.fetchall() + if ret is not None: + for record in result: + if record.perm_name == permission: + return True + + return False + + +@asyncio.coroutine +def check_credentials(db_engine, username, password): + with (yield from db_engine) as conn: + where = sa.and_(db.users.c.login == username, + sa.not_(db.users.c.disabled)) + query = db.users.select().where(where) + ret = yield from conn.execute(query) + user = yield from ret.fetchone() + if user is not None: + hash = user[2] + return sha256_crypt.verify(password, hash) + return False diff --git a/demo/handlers.py b/demo/handlers.py index 313611e..3de2aed 100644 --- a/demo/handlers.py +++ b/demo/handlers.py @@ -3,53 +3,93 @@ import functools from aiohttp import web - from aiohttp_security import remember, forget, authorized_userid, permits +from .db_auth import check_credentials + def require(permission): def wrapper(f): @asyncio.coroutine @functools.wraps(f) def wrapped(self, request): - has_perm = yield from permits(request) + has_perm = yield from permits(request, permission) if not has_perm: - raise web.HTTPForbidden() + message = 'User has no permission {}'.format(permission) + raise web.HTTPForbidden(body=message.encode()) return (yield from f(self, request)) return wrapped return wrapper -class Web: - @require('public') +class Web(object): + index_template = """ + + + + +

{message}

+
+ Login: + + Password: + + +
+Logout + +""" + @asyncio.coroutine def index(self, request): - pass + username = yield from authorized_userid(request) + if username: + template = self.index_template.format( + message='Hello, {username}!'.format(username=username)) + else: + template = self.index_template.format(message='You need to login') + response = web.Response(body=template.encode()) + return response - @require('public') @asyncio.coroutine def login(self, request): - pass + response = web.HTTPFound('/') + form = yield from request.post() + login = form.get('login') + password = form.get('password') + db_engine = request.app.db_engine + if (yield from check_credentials(db_engine, login, password)): + yield from remember(request, response, login) + return response - @require('protected') - @asyncio.coroutine - def logout(self, request): - pass + return web.HTTPUnauthorized( + body=b'Invalid username/password combination') @require('public') @asyncio.coroutine - def public(self, request): - pass + def logout(self, request): + response = web.Response(body=b'You have been logged out') + yield from forget(request, response) + return response + + @require('public') + @asyncio.coroutine + def internal_page(self, request): + response = web.Response( + body=b'This page is visible for all registered users') + return response @require('protected') @asyncio.coroutine - def protected(self, request): - pass + def protected_page(self, request): + response = web.Response(body=b'You are on protected page') + return response - @asyncio.coroutine def configure(self, app): - app.add_route('GET', '/', self.index, name='index') - app.add_route('POST', '/login', self.login, name='login') - app.add_route('POST', '/logout', self.logout, name='logout') - app.add_route('GET', '/public', self.public, name='public') - app.add_route('GET', '/protected', self.protected, name='protected') + router = app.router + router.add_route('GET', '/', self.index, name='index') + router.add_route('POST', '/login', self.login, name='login') + router.add_route('GET', '/logout', self.logout, name='logout') + router.add_route('GET', '/public', self.internal_page, name='public') + router.add_route('GET', '/protected', self.protected_page, + name='protected') diff --git a/demo/main.py b/demo/main.py index 29b4ead..4fb3211 100644 --- a/demo/main.py +++ b/demo/main.py @@ -16,22 +16,23 @@ from demo.handlers import Web @asyncio.coroutine def init(loop): redis_pool = yield from create_pool(('localhost', 6379)) - dbengine = yield from create_engine(user='aiohttp_security', - password='aiohttp_security', - database='aiohttp_security', - host='127.0.0.1') + db_engine = yield from create_engine(user='aiohttp_security', + password='aiohttp_security', + database='aiohttp_security', + host='127.0.0.1') app = web.Application(loop=loop) + app.db_engine = db_engine setup_session(app, RedisStorage(redis_pool)) setup_security(app, SessionIdentityPolicy(), - DBAuthorizationPolicy(dbengine)) + DBAuthorizationPolicy(db_engine)) web_handlers = Web() - yield from web_handlers.configure(app) + web_handlers.configure(app) handler = app.make_handler() srv = yield from loop.create_server(handler, '127.0.0.1', 8080) - print("Server started at http://127.0.0.1:8080") + print('Server started at http://127.0.0.1:8080') return srv, app, handler @@ -54,3 +55,7 @@ def main(): loop.run_forever() except KeyboardInterrupt: loop.run_until_complete((finalize(srv, app, handler))) + + +if __name__ == '__main__': + main() diff --git a/demo/sql/init_db.sql b/demo/sql/init_db.sql new file mode 100644 index 0000000..2a53f94 --- /dev/null +++ b/demo/sql/init_db.sql @@ -0,0 +1,5 @@ +CREATE USER aiohttp_security WITH PASSWORD 'aiohttp_security'; +DROP DATABASE IF EXISTS aiohttp_security; +CREATE DATABASE aiohttp_security; +ALTER DATABASE aiohttp_security OWNER TO aiohttp_security; +GRANT ALL PRIVILEGES ON DATABASE aiohttp_security TO aiohttp_security; diff --git a/demo/sql/sample_data.sql b/demo/sql/sample_data.sql new file mode 100644 index 0000000..3a4a37e --- /dev/null +++ b/demo/sql/sample_data.sql @@ -0,0 +1,38 @@ +-- create users table +CREATE TABLE IF NOT EXISTS users +( + id integer NOT NULL, + login character varying(256) NOT NULL, + passwd character varying(256) NOT NULL, + is_superuser boolean NOT NULL DEFAULT false, + disabled boolean NOT NULL DEFAULT false, + CONSTRAINT user_pkey PRIMARY KEY (id), + CONSTRAINT user_login_key UNIQUE (login) +); + +-- and permissions for them +CREATE TABLE IF NOT EXISTS permissions +( + id integer NOT NULL, + user_id integer NOT NULL, + perm_name character varying(64) NOT NULL, + CONSTRAINT permission_pkey PRIMARY KEY (id), + CONSTRAINT user_permission_fkey FOREIGN KEY (user_id) + REFERENCES users (id) MATCH SIMPLE + ON UPDATE NO ACTION ON DELETE CASCADE +); + +-- insert some data +INSERT INTO users(id, login, passwd, is_superuser, disabled) +VALUES (1, 'admin', '$5$rounds=535000$2kqN9fxCY6Xt5/pi$tVnh0xX87g/IsnOSuorZG608CZDFbWIWBr58ay6S4pD', TRUE, FALSE); +INSERT INTO users(id, login, passwd, is_superuser, disabled) +VALUES (2, 'moderator', '$5$rounds=535000$2kqN9fxCY6Xt5/pi$tVnh0xX87g/IsnOSuorZG608CZDFbWIWBr58ay6S4pD', FALSE, FALSE); +INSERT INTO users(id, login, passwd, is_superuser, disabled) +VALUES (3, 'user', '$5$rounds=535000$2kqN9fxCY6Xt5/pi$tVnh0xX87g/IsnOSuorZG608CZDFbWIWBr58ay6S4pD', FALSE, FALSE); + +INSERT INTO permissions(id, user_id, perm_name) +VALUES (1, 2, 'protected'); +INSERT INTO permissions(id, user_id, perm_name) +VALUES (2, 2, 'public'); +INSERT INTO permissions(id, user_id, perm_name) +VALUES (3, 3, 'public'); diff --git a/docs/example.rst b/docs/example.rst index 9a29c8d..e50ba38 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -1,8 +1,11 @@ +.. _aiohttp-security-example: + +=============================================== How to Make a Simple Server With Authorization -============================================== +=============================================== -.. code::python +Simple example:: import asyncio from aiohttp import web @@ -13,7 +16,7 @@ How to Make a Simple Server With Authorization return web.Response(body=text.encode('utf-8')) # option 2: auth at a higher level? - # set user_id and allowed in the wsgo handler + # set user_id and allowed in the wsgi handler @protect('view_user') @asyncio.coroutine def user_handler(request): diff --git a/docs/example_db_auth.rst b/docs/example_db_auth.rst new file mode 100644 index 0000000..aea5a84 --- /dev/null +++ b/docs/example_db_auth.rst @@ -0,0 +1,210 @@ +.. _aiohttp-security-example-db-auth: + +=========================================== +Permissions with PostgreSQL-based storage +=========================================== + +Make sure that you have PostgreSQL and Redis servers up and running. +If you want the full source code in advance or for comparison, check out +the `demo source`_. + +.. _demo source: + https://github.com/aio-libs/aiohttp_security/tree/master/demo + +.. _passlib: + https://pythonhosted.org/passlib/ + +Database +-------- + +Launch these sql scripts to init database and fill it with sample data: + +``psql template1 < demo/sql/init_db.sql`` + +and then + +``psql template1 < demo/sql/sample_data.sql`` + + +You will have two tables for storing users and their permissions + ++--------------+ +| users | ++==============+ +| id | ++--------------+ +| login | ++--------------+ +| passwd | ++--------------+ +| is_superuser | ++--------------+ +| disabled | ++--------------+ + +and second table is permissions table: + ++-----------------+ +| permissions | ++=================+ +| id | ++-----------------+ +| user_id | ++-----------------+ +| permission_name | ++-----------------+ + + +Writing policies +---------------- + +You need to implement two entities: *IdentityPolicy* and *AuthorizationPolicy*. +First one should have these methods: *identify*, *remember* and *forget*. +For second one: *authorized_userid* and *permits*. We will use built-in +*SessionIdentityPolicy* and write our own database-based authorization policy. + +In our example we will lookup database by user login and if present return +this identity:: + + + @asyncio.coroutine + def authorized_userid(self, identity): + with (yield from self.dbengine) as conn: + where = sa.and_(db.users.c.login == identity, + sa.not_(db.users.c.disabled)) + query = db.users.count().where(where) + ret = yield from conn.scalar(query) + if ret: + return identity + else: + return None + + +For permission check we will fetch the user first, check if he is superuser +(all permissions are allowed), otherwise check if permission is explicitly set +for that user:: + + @asyncio.coroutine + def permits(self, identity, permission, context=None): + if identity is None: + return False + + with (yield from self.dbengine) as conn: + where = sa.and_(db.users.c.login == identity, + sa.not_(db.users.c.disabled)) + query = db.users.select().where(where) + ret = yield from conn.execute(query) + user = yield from ret.fetchone() + if user is not None: + user_id = user[0] + is_superuser = user[4] + if is_superuser: + return True + + where = db.permissions.c.user_id == user_id + query = db.permissions.select().where(where) + ret = yield from conn.execute(query) + result = yield from ret.fetchall() + if ret is not None: + for record in result: + if record.perm_name == permission: + return True + + return False + + +Setup +----- + +Once we have all the code in place we can install it for our application:: + + from aiohttp_session.redis_storage import RedisStorage + from aiohttp_security import setup as setup_security + from aiohttp_security import SessionIdentityPolicy + from aiopg.sa import create_engine + from aioredis import create_pool + + from .db_auth import DBAuthorizationPolicy + + + @asyncio.coroutine + def init(loop): + redis_pool = yield from create_pool(('localhost', 6379)) + dbengine = yield from create_engine(user='aiohttp_security', + password='aiohttp_security', + database='aiohttp_security', + host='127.0.0.1') + app = web.Application(loop=loop) + setup_session(app, RedisStorage(redis_pool)) + setup_security(app, + SessionIdentityPolicy(), + DBAuthorizationPolicy(dbengine)) + return app + + +Now we have authorization and can decorate every other view with access rights +based on permissions. This simple decorator (for class-based handlers) will +help to do that:: + + def require(permission): + def wrapper(f): + @asyncio.coroutine + @functools.wraps(f) + def wrapped(self, request): + has_perm = yield from permits(request, permission) + if not has_perm: + message = 'User has no permission {}'.format(permission) + raise web.HTTPForbidden(body=message.encode()) + return (yield from f(self, request)) + return wrapped + return wrapper + + +For each view you need to protect just apply the decorator on it:: + + class Web: + @require('protected') + @asyncio.coroutine + def protected_page(self, request): + response = web.Response(body=b'You are on protected page') + return response + + +If someone will try to access this protected page he will see:: + + 403, User has no permission "protected" + + +The best part about it is that you can implement any logic you want until it +follows the API conventions. + +Launch application +------------------ + +For working with passwords there is a good library passlib_. Once you've +created some users you want to check their credentials on login. Similar +function may do what you trying to accomplish:: + + from passlib.hash import sha256_crypt + + @asyncio.coroutine + def check_credentials(db_engine, username, password): + with (yield from db_engine) as conn: + where = sa.and_(db.users.c.login == username, + sa.not_(db.users.c.disabled)) + query = db.users.select().where(where) + ret = yield from conn.execute(query) + user = yield from ret.fetchone() + if user is not None: + hash = user[2] + return sha256_crypt.verify(password, hash) + return False + + +Final step is to launch your application:: + + python demo/main.py + + +Try to login with admin/moderator/user accounts (with *password* password) +and access **/public** or **/protected** endpoints. diff --git a/docs/index.rst b/docs/index.rst index c28aec6..7552a3c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -20,6 +20,7 @@ Contents: usage reference example + example_db_auth glossary diff --git a/requirements-dev.txt b/requirements-dev.txt index ae25724..e23faf8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,3 +8,6 @@ alabaster>=0.6.2 pep257 aiohttp_session>=0.4.0 aiopg[sa] +aioredis==0.2.8 +hiredis==0.2.0 +passlib==1.6.5 diff --git a/tests/conftest.py b/tests/conftest.py index 31f69cc..64260bd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,10 @@ -import aiohttp -import asyncio import gc -import pytest import socket +import asyncio + +import pytest +import aiohttp + from aiohttp import web @@ -79,7 +81,8 @@ class Client: while path.startswith('/'): path = path[1:] url = self._url + path - return self._session.get(url, **kwargs) + resp = self._session.get(url, **kwargs) + return resp def post(self, path, **kwargs): while path.startswith('/'): @@ -97,6 +100,7 @@ class Client: @pytest.yield_fixture def create_app_and_client(create_server, loop): client = None + cookie_jar = aiohttp.CookieJar(loop=loop, unsafe=True) @asyncio.coroutine def maker(*, server_params=None, client_params=None): @@ -108,7 +112,11 @@ def create_app_and_client(create_server, loop): app, url = yield from create_server(**server_params) if client_params is None: client_params = {} - client = Client(aiohttp.ClientSession(loop=loop, **client_params), url) + + client = Client( + aiohttp.ClientSession(loop=loop, cookie_jar=cookie_jar), + url + ) return app, client yield maker diff --git a/tests/test_cookies_identity.py b/tests/test_cookies_identity.py index 59a691f..edb7112 100644 --- a/tests/test_cookies_identity.py +++ b/tests/test_cookies_identity.py @@ -34,7 +34,7 @@ def test_remember(create_app_and_client): app.router.add_route('GET', '/', handler) resp = yield from client.get('/') assert 200 == resp.status - assert 'Andrew' == client.cookies['AIOHTTP_SECURITY'].value + assert 'Andrew' == resp.cookies['AIOHTTP_SECURITY'].value yield from resp.release() @@ -98,5 +98,6 @@ def test_forget(create_app_and_client): resp = yield from client.post('/logout') assert 200 == resp.status assert resp.url.endswith('/') - assert '' == client.cookies['AIOHTTP_SECURITY'].value + with pytest.raises(KeyError): + _ = client.cookies['AIOHTTP_SECURITY'] # noqa yield from resp.release() diff --git a/tests/test_session_identity.py b/tests/test_session_identity.py index d133ea6..81450f7 100644 --- a/tests/test_session_identity.py +++ b/tests/test_session_identity.py @@ -7,8 +7,8 @@ from aiohttp_security import (remember, forget, from aiohttp_security import setup as setup_security from aiohttp_security.session_identity import SessionIdentityPolicy from aiohttp_security.api import IDENTITY_KEY -from aiohttp_session import (SimpleCookieStorage, session_middleware, - get_session) +from aiohttp_session import SimpleCookieStorage, get_session +from aiohttp_session import setup as setup_session class Autz(AbstractAuthorizationPolicy): @@ -27,7 +27,7 @@ def create_app_and_client2(create_app_and_client): @asyncio.coroutine def maker(*args, **kwargs): app, client = yield from create_app_and_client(*args, **kwargs) - app.middlewares.append(session_middleware(SimpleCookieStorage())) + setup_session(app, SimpleCookieStorage()) setup_security(app, SessionIdentityPolicy(), Autz()) return app, client return maker @@ -82,6 +82,7 @@ def test_identify(create_app_and_client2): resp = yield from client.post('/') assert 200 == resp.status yield from resp.release() + resp = yield from client.get('/') assert 200 == resp.status yield from resp.release() @@ -103,7 +104,7 @@ def test_forget(create_app_and_client2): @asyncio.coroutine def logout(request): - response = web.HTTPFound(location='/') + response = web.HTTPFound('/') yield from forget(request, response) return response @@ -111,12 +112,14 @@ def test_forget(create_app_and_client2): app.router.add_route('GET', '/', index) app.router.add_route('POST', '/login', login) app.router.add_route('POST', '/logout', logout) + resp = yield from client.post('/login') assert 200 == resp.status assert resp.url.endswith('/') txt = yield from resp.text() assert 'Andrew' == txt yield from resp.release() + resp = yield from client.post('/logout') assert 200 == resp.status assert resp.url.endswith('/')